diff --git a/.codespellignore b/.codespellignore
new file mode 100644
index 0000000..a6d3a93
--- /dev/null
+++ b/.codespellignore
@@ -0,0 +1 @@
+toi
\ No newline at end of file
diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml
index de2e6bc..84777d6 100644
--- a/.github/workflows/code-testing.yml
+++ b/.github/workflows/code-testing.yml
@@ -43,7 +43,7 @@ jobs:
- 'docs/**'
- 'README.md'
check-requirements:
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
@@ -62,7 +62,7 @@ jobs:
# @gmuloc: commenting this out for now
#missing-documentation:
# name: "Warning documentation is missing"
- # runs-on: ubuntu-20.04
+ # runs-on: ubuntu-latest
# needs: [file-changes]
# if: needs.file-changes.outputs.cli == 'true' && needs.file-changes.outputs.docs == 'false'
# steps:
@@ -74,7 +74,7 @@ jobs:
# You should update documentation to reflect your change, or maybe not :)
lint-python:
name: Check the code style
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
needs: file-changes
if: needs.file-changes.outputs.code == 'true'
steps:
@@ -89,7 +89,7 @@ jobs:
run: tox -e lint
type-python:
name: Check typing
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
needs: file-changes
if: needs.file-changes.outputs.code == 'true'
steps:
@@ -104,7 +104,7 @@ jobs:
run: tox -e type
test-python:
name: Pytest across all supported python versions
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
needs: [lint-python, type-python]
strategy:
matrix:
@@ -138,7 +138,7 @@ jobs:
run: tox
test-documentation:
name: Build offline documentation for testing
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
needs: [test-python]
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4b3b357..c0a538f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,31 +27,9 @@ jobs:
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- release-coverage:
- name: Updated ANTA release coverage badge
- runs-on: ubuntu-20.04
- needs: [pypi]
- steps:
- - uses: actions/checkout@v4
- - name: Setup Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.11"
- - name: Install dependencies
- run: pip install genbadge[coverage] tox tox-gh-actions
- - name: "Run pytest via tox for ${{ matrix.python }}"
- run: tox
- - name: Generate coverage badge
- run: genbadge coverage -i .coverage.xml -o badge/latest-release-coverage.svg
- - name: Publish coverage badge to gh-pages branch
- uses: JamesIves/github-pages-deploy-action@v4
- with:
- branch: coverage-badge
- folder: badge
release-doc:
name: "Publish documentation for release ${{github.ref_name}}"
runs-on: ubuntu-latest
- needs: [release-coverage]
steps:
- uses: actions/checkout@v4
with:
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index 81db36e..7ec7896 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+ fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Python
uses: actions/setup-python@v5
with:
@@ -30,7 +30,7 @@ jobs:
- name: "Run pytest via tox for ${{ matrix.python }}"
run: tox
- name: SonarCloud Scan
- uses: SonarSource/sonarcloud-github-action@master
+ uses: SonarSource/sonarqube-scan-action@v5.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f33db65..7dde835 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,6 +3,7 @@
# See https://pre-commit.com/hooks.html for more hooks
ci:
autoupdate_commit_msg: "ci: pre-commit autoupdate"
+ skip: [mypy]
files: ^(anta|docs|scripts|tests|asynceapi)/
@@ -43,28 +44,28 @@ repos:
- --allow-past-years
- --fuzzy-match-generates-todo
- --comment-style
- - ''
+ - ""
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.8.4
+ rev: v0.10.0
hooks:
- - id: ruff
- name: Run Ruff linter
- args: [ --fix ]
- - id: ruff-format
- name: Run Ruff formatter
+ - id: ruff
+ name: Run Ruff linter
+ args: [--fix]
+ - id: ruff-format
+ name: Run Ruff formatter
- repo: https://github.com/pycqa/pylint
- rev: "v3.3.2"
+ rev: "v3.3.5"
hooks:
- id: pylint
name: Check code style with pylint
description: This hook runs pylint.
types: [python]
args:
- - -rn # Only display messages
- - -sn # Don't display the score
- - --rcfile=pyproject.toml # Link to config file
+ - -rn # Only display messages
+ - -sn # Don't display the score
+ - --rcfile=pyproject.toml # Link to config file
additional_dependencies:
- anta[cli]
- types-PyYAML
@@ -76,16 +77,17 @@ repos:
- respx
- repo: https://github.com/codespell-project/codespell
- rev: v2.3.0
+ rev: v2.4.1
hooks:
- id: codespell
name: Checks for common misspellings in text files.
entry: codespell
language: python
types: [text]
+ args: ["--ignore-words", ".codespellignore"]
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.14.0
+ rev: v1.15.0
hooks:
- id: mypy
name: Check typing with mypy
@@ -97,10 +99,10 @@ repos:
- types-requests
- types-pyOpenSSL
- pytest
- files: ^(anta|tests)/
+ files: ^(anta|tests|asynceapi)/
- repo: https://github.com/igorshubovych/markdownlint-cli
- rev: v0.43.0
+ rev: v0.44.0
hooks:
- id: markdownlint
name: Check Markdown files style.
@@ -122,5 +124,14 @@ repos:
pass_filenames: false
additional_dependencies:
- anta[cli]
- # TODO: next can go once we have it added to anta properly
- - numpydoc
+ - id: doc-snippets
+ name: Generate doc snippets
+ entry: >-
+ sh -c "docs/scripts/generate_doc_snippets.py"
+ language: python
+ types: [python]
+ files: anta/cli/
+ verbose: true
+ pass_filenames: false
+ additional_dependencies:
+ - anta[cli]
diff --git a/anta/__init__.py b/anta/__init__.py
index 6660843..339a7d3 100644
--- a/anta/__init__.py
+++ b/anta/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Arista Network Test Automation (ANTA) Framework."""
diff --git a/anta/catalog.py b/anta/catalog.py
index bc95104..5239255 100644
--- a/anta/catalog.py
+++ b/anta/catalog.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Catalog related functions."""
@@ -14,11 +14,11 @@ from itertools import chain
from json import load as json_load
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
-from warnings import warn
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator
from pydantic.types import ImportString
from pydantic_core import PydanticCustomError
+from typing_extensions import deprecated
from yaml import YAMLError, safe_dump, safe_load
from anta.logger import anta_log_exception
@@ -182,7 +182,7 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
except Exception as e:
# A test module is potentially user-defined code.
# We need to catch everything if we want to have meaningful logs
- module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
+ module_str = f"{module_name.removeprefix('.')}{f' from package {package}' if package else ''}"
message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues."
anta_log_exception(e, message, logger)
raise ValueError(message) from e
@@ -223,16 +223,14 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
if len(test_definition) != 1:
msg = (
- f"Syntax error when parsing: {test_definition}\n"
- "It must be a dictionary with a single entry. Check the indentation in the test catalog."
+ f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
)
raise ValueError(msg)
for test_name, test_inputs in test_definition.copy().items():
test: type[AntaTest] | None = getattr(module, test_name, None)
if test is None:
msg = (
- f"{test_name} is not defined in Python module {module.__name__}"
- f"{f' (from {module.__file__})' if module.__file__ is not None else ''}"
+ f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
)
raise ValueError(msg)
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))
@@ -252,7 +250,7 @@ class AntaCatalogFile(RootModel[dict[ImportString[Any], list[AntaTestDefinition]
# This could be improved.
# https://github.com/pydantic/pydantic/issues/1043
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
- return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
+ return safe_dump(safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), width=math.inf)
def to_json(self) -> str:
"""Return a JSON representation string of this model.
@@ -291,11 +289,7 @@ class AntaCatalog:
self._tests = tests
self._filename: Path | None = None
if filename is not None:
- if isinstance(filename, Path):
- self._filename = filename
- else:
- self._filename = Path(filename)
-
+ self._filename = filename if isinstance(filename, Path) else Path(filename)
self.indexes_built: bool
self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]]
self._init_indexes()
@@ -325,6 +319,8 @@ class AntaCatalog:
msg = "A test in the catalog must be an AntaTestDefinition instance"
raise TypeError(msg)
self._tests = value
+ # Tests were modified so indexes need to be rebuilt.
+ self.clear_indexes()
@staticmethod
def parse(filename: str | Path, file_format: Literal["yaml", "json"] = "yaml") -> AntaCatalog:
@@ -440,13 +436,12 @@ class AntaCatalog:
combined_tests = list(chain(*(catalog.tests for catalog in catalogs)))
return cls(tests=combined_tests)
+ @deprecated(
+ "This method is deprecated, use `AntaCatalogs.merge_catalogs` class method instead. This will be removed in ANTA v2.0.0.", category=DeprecationWarning
+ )
def merge(self, catalog: AntaCatalog) -> AntaCatalog:
"""Merge two AntaCatalog instances.
- Warning
- -------
- This method is deprecated and will be removed in ANTA v2.0. Use `AntaCatalog.merge_catalogs()` instead.
-
Parameters
----------
catalog
@@ -457,12 +452,6 @@ class AntaCatalog:
AntaCatalog
A new AntaCatalog instance containing the tests of the two instances.
"""
- # TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754
- warn(
- message="AntaCatalog.merge() is deprecated and will be removed in ANTA v2.0. Use AntaCatalog.merge_catalogs() instead.",
- category=DeprecationWarning,
- stacklevel=2,
- )
return self.merge_catalogs([self, catalog])
def dump(self) -> AntaCatalogFile:
diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py
index 90be5c7..dd39f78 100644
--- a/anta/cli/__init__.py
+++ b/anta/cli/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
diff --git a/anta/cli/_main.py b/anta/cli/_main.py
index ae4e050..1dc6224 100644
--- a/anta/cli/_main.py
+++ b/anta/cli/_main.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py
index bbc5a7e..ab1b08e 100644
--- a/anta/cli/check/__init__.py
+++ b/anta/cli/check/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands to validate configuration files."""
diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py
index 23895d7..2ca6013 100644
--- a/anta/cli/check/commands.py
+++ b/anta/cli/check/commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name
diff --git a/anta/cli/console.py b/anta/cli/console.py
index 9c57d6d..068e676 100644
--- a/anta/cli/console.py
+++ b/anta/cli/console.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""ANTA Top-level Console.
diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py
index 18d577f..d3ff5bf 100644
--- a/anta/cli/debug/__init__.py
+++ b/anta/cli/debug/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands to execute EOS commands on remote devices."""
diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py
index e6e456e..54f580a 100644
--- a/anta/cli/debug/commands.py
+++ b/anta/cli/debug/commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name
diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py
index 454c3e6..c8ead5a 100644
--- a/anta/cli/debug/utils.py
+++ b/anta/cli/debug/utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.debug module."""
diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py
index 5fa6eb9..bcec37c 100644
--- a/anta/cli/exec/__init__.py
+++ b/anta/cli/exec/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands to execute various scripts on EOS devices."""
diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py
index ff36e56..a299393 100644
--- a/anta/cli/exec/commands.py
+++ b/anta/cli/exec/commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands to execute various scripts on EOS devices."""
diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py
index 33a0222..3258d0b 100644
--- a/anta/cli/exec/utils.py
+++ b/anta/cli/exec/utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
@@ -13,6 +13,7 @@ import logging
from pathlib import Path
from typing import TYPE_CHECKING, Literal
+from asyncssh.misc import HostKeyNotVerifiable
from click.exceptions import UsageError
from httpx import ConnectError, HTTPError
@@ -23,6 +24,7 @@ from asynceapi import EapiCommandError
if TYPE_CHECKING:
from anta.inventory import AntaInventory
+ from asynceapi._types import EapiComplexCommand, EapiSimpleCommand
EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support"
INVALID_CHAR = "`~!@#$/"
@@ -96,7 +98,7 @@ async def collect_commands(
logger.error("Error when collecting commands: %s", str(r))
-async def collect_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: # noqa: C901
"""Collect scheduled show-tech on devices."""
async def collect(device: AntaDevice) -> None:
@@ -135,13 +137,13 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
)
logger.warning(msg)
- 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)
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = []
if device.enable and device._enable_password is not None:
commands.append({"cmd": "enable", "input": device._enable_password})
elif device.enable:
@@ -162,6 +164,11 @@ async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bo
await device.copy(sources=filenames, destination=outdir, direction="from")
logger.info("Collected %s scheduled tech-support from %s", len(filenames), device.name)
+ except HostKeyNotVerifiable:
+ logger.error(
+ "Unable to collect tech-support on %s. The host SSH key could not be verified. Make sure it is part of the `known_hosts` file on your machine.",
+ device.name,
+ )
except (EapiCommandError, HTTPError, ConnectError) as e:
logger.error("Unable to collect tech-support on %s: %s", device.name, str(e))
diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py
index 8763b35..d0393ad 100644
--- a/anta/cli/get/__init__.py
+++ b/anta/cli/get/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands to get information from or generate inventories."""
diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py
index 3cc9126..e34be2c 100644
--- a/anta/cli/get/commands.py
+++ b/anta/cli/get/commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# pylint: disable = redefined-outer-name
diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py
index d21dc54..e609065 100644
--- a/anta/cli/get/utils.py
+++ b/anta/cli/get/utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.get.commands module."""
@@ -350,17 +350,18 @@ def print_test(test: type[AntaTest], *, short: bool = False) -> None:
# Need to handle the fact that we nest the routing modules in Examples.
# This is a bit fragile.
inputs = example.split("\n")
- try:
- test_name_line = next((i for i, input_entry in enumerate(inputs) if test.name in input_entry))
- except StopIteration as e:
+ test_name_lines = [i for i, input_entry in enumerate(inputs) if test.name in input_entry]
+ if not test_name_lines:
msg = f"Could not find the name of the test '{test.name}' in the Example section in the docstring."
- raise ValueError(msg) from e
- # TODO: handle not found
- console.print(f" {inputs[test_name_line].strip()}")
- # Injecting the description
- console.print(f" # {test.description}", soft_wrap=True)
- if not short and len(inputs) > test_name_line + 2: # There are params
- console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[test_name_line + 1 : -1])), " " * 6))
+ raise ValueError(msg)
+ for list_index, line_index in enumerate(test_name_lines):
+ end = test_name_lines[list_index + 1] if list_index + 1 < len(test_name_lines) else -1
+ console.print(f" {inputs[line_index].strip()}")
+ # Injecting the description for the first example
+ if list_index == 0:
+ console.print(f" # {test.description}", soft_wrap=True)
+ if not short and len(inputs) > line_index + 2: # There are params
+ console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[line_index + 1 : end])), " " * 6))
def extract_examples(docstring: str) -> str | None:
diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py
index 0272e0d..6dc912d 100644
--- a/anta/cli/nrfu/__init__.py
+++ b/anta/cli/nrfu/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands that run ANTA tests using anta.runner."""
@@ -42,9 +42,10 @@ class IgnoreRequiredWithHelp(AliasedGroup):
if "--help" not in args:
raise
- # remove the required params so that help can display
+ # Fake presence of the required params so that help can display
for param in self.params:
- param.required = False
+ if param.required:
+ param.value_is_missing = lambda value: False # type: ignore[method-assign] # noqa: ARG005
return super().parse_args(ctx, args)
diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py
index a549268..ed0f432 100644
--- a/anta/cli/nrfu/commands.py
+++ b/anta/cli/nrfu/commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Click commands that render ANTA tests results."""
@@ -45,7 +45,10 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non
help="Path to save report as a JSON file",
)
def json(ctx: click.Context, output: pathlib.Path | None) -> None:
- """ANTA command to check network state with JSON results."""
+ """ANTA command to check network state with JSON results.
+
+ If no `--output` is specified, the output is printed to stdout.
+ """
run_tests(ctx)
print_json(ctx, output=output)
exit_with_code(ctx)
@@ -72,11 +75,11 @@ def text(ctx: click.Context) -> None:
path_type=pathlib.Path,
),
show_envvar=True,
- required=False,
+ required=True,
help="Path to save report as a CSV file",
)
def csv(ctx: click.Context, csv_output: pathlib.Path) -> None:
- """ANTA command to check network states with CSV result."""
+ """ANTA command to check network state with CSV report."""
run_tests(ctx)
save_to_csv(ctx, csv_file=csv_output)
exit_with_code(ctx)
diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py
index 375e6e1..60c0d29 100644
--- a/anta/cli/nrfu/utils.py
+++ b/anta/cli/nrfu/utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Utils functions to use with anta.cli.nrfu.commands module."""
@@ -157,7 +157,7 @@ def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None:
Path to save the markdown report.
"""
try:
- MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output)
+ MDReportGenerator.generate(results=_get_result_manager(ctx).sort(["name", "categories", "test"]), md_filename=md_output)
console.print(f"Markdown report saved to {md_output} ✅", style="cyan")
except OSError:
console.print(f"Failed to save Markdown report to {md_output} ❌", style="cyan")
diff --git a/anta/cli/utils.py b/anta/cli/utils.py
index a939c32..34b96b3 100644
--- a/anta/cli/utils.py
+++ b/anta/cli/utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Utils functions to use with anta.cli module."""
@@ -9,7 +9,7 @@ import enum
import functools
import logging
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, Literal
import click
from yaml import YAMLError
@@ -17,6 +17,7 @@ from yaml import YAMLError
from anta.catalog import AntaCatalog
from anta.inventory import AntaInventory
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
+from anta.logger import anta_log_exception
if TYPE_CHECKING:
from click import Option
@@ -190,6 +191,14 @@ def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
required=True,
type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path),
)
+ @click.option(
+ "--inventory-format",
+ envvar="ANTA_INVENTORY_FORMAT",
+ show_envvar=True,
+ help="Format of the inventory file, either 'yaml' or 'json'",
+ default="yaml",
+ type=click.Choice(["yaml", "json"], case_sensitive=False),
+ )
@click.pass_context
@functools.wraps(f)
def wrapper(
@@ -204,6 +213,7 @@ def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
timeout: float,
insecure: bool,
disable_cache: bool,
+ inventory_format: Literal["json", "yaml"],
**kwargs: dict[str, Any],
) -> Any:
# If help is invoke somewhere, do not parse inventory
@@ -241,8 +251,10 @@ def core_options(f: Callable[..., Any]) -> Callable[..., Any]:
timeout=timeout,
insecure=insecure,
disable_cache=disable_cache,
+ file_format=inventory_format,
)
- except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
+ except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError) as e:
+ anta_log_exception(e, f"Failed to parse the inventory: {inventory}", logger)
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, inventory=i, **kwargs)
@@ -319,7 +331,8 @@ def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]:
try:
file_format = catalog_format.lower()
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
- except (TypeError, ValueError, YAMLError, OSError):
+ except (TypeError, ValueError, YAMLError, OSError) as e:
+ anta_log_exception(e, f"Failed to parse the catalog: {catalog}", logger)
ctx.exit(ExitCode.USAGE_ERROR)
return f(*args, catalog=c, **kwargs)
diff --git a/anta/constants.py b/anta/constants.py
index 4dcef30..2a4c0c9 100644
--- a/anta/constants.py
+++ b/anta/constants.py
@@ -1,11 +1,30 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Constants used in ANTA."""
from __future__ import annotations
-ACRONYM_CATEGORIES: set[str] = {"aaa", "mlag", "snmp", "bgp", "ospf", "vxlan", "stp", "igmp", "ip", "lldp", "ntp", "bfd", "ptp", "lanz", "stun", "vlan"}
+ACRONYM_CATEGORIES: set[str] = {
+ "aaa",
+ "avt",
+ "bfd",
+ "bgp",
+ "igmp",
+ "ip",
+ "isis",
+ "lanz",
+ "lldp",
+ "mlag",
+ "ntp",
+ "ospf",
+ "ptp",
+ "snmp",
+ "stp",
+ "stun",
+ "vlan",
+ "vxlan",
+}
"""A set of network protocol or feature acronyms that should be represented in uppercase."""
MD_REPORT_TOC = """**Table of Contents:**
@@ -24,5 +43,33 @@ KNOWN_EOS_ERRORS = [
r".* does not support IP",
r"IS-IS (.*) is disabled because: .*",
r"No source interface .*",
+ r".*controller\snot\sready.*",
]
-"""List of known EOS errors that should set a test status to 'failure' with the error message."""
+"""List of known EOS errors.
+
+!!! failure "Generic EOS Error Handling"
+ When catching these errors, **ANTA will fail the affected test** and reported the error message.
+"""
+
+EOS_BLACKLIST_CMDS = [
+ r"^reload.*",
+ r"^conf.*",
+ r"^wr.*",
+]
+"""List of blacklisted EOS commands.
+
+!!! success "Disruptive commands safeguard"
+ ANTA implements a mechanism to **prevent the execution of disruptive commands** such as `reload`, `write erase` or `configure terminal`.
+"""
+
+UNSUPPORTED_PLATFORM_ERRORS = [
+ "not supported on this hardware platform",
+ "Invalid input (at token 2: 'trident')",
+]
+"""Error messages indicating platform or hardware unsupported commands. Includes both general hardware
+platform errors and specific ASIC family limitations.
+
+!!! tip "Running EOS commands unsupported by hardware"
+ When catching these errors, ANTA will skip the affected test and raise a warning. The **test catalog must be updated** to remove execution of the affected test
+ on unsupported devices.
+"""
diff --git a/anta/custom_types.py b/anta/custom_types.py
index 297f1f5..92edabc 100644
--- a/anta/custom_types.py
+++ b/anta/custom_types.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module that provides predefined types for AntaTest.Input instances."""
@@ -10,9 +10,6 @@ from pydantic import Field
from pydantic.functional_validators import AfterValidator, BeforeValidator
# Regular Expression definition
-# TODO: make this configurable - with an env var maybe?
-REGEXP_EOS_BLACKLIST_CMDS = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"]
-"""List of regular expressions to blacklist from eos commands."""
REGEXP_PATH_MARKERS = r"[\\\/\s]"
"""Match directory path from string."""
REGEXP_INTERFACE_ID = r"\d+(\/\d+)*(\.\d+)?"
@@ -26,15 +23,12 @@ REGEX_TYPE_PORTCHANNEL = r"^Port-Channel[0-9]{1,6}$"
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."""
+
+# Regular expression for BGP redistributed routes
+REGEX_IPV4_UNICAST = r"ipv4[-_ ]?unicast$"
+REGEX_IPV4_MULTICAST = r"ipv4[-_ ]?multicast$"
+REGEX_IPV6_UNICAST = r"ipv6[-_ ]?unicast$"
+REGEX_IPV6_MULTICAST = r"ipv6[-_ ]?multicast$"
def aaa_group_prefix(v: str) -> str:
@@ -58,7 +52,7 @@ def interface_autocomplete(v: str) -> str:
raise ValueError(msg)
intf_id = m[0]
- alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"}
+ alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback", "vl": "Vlan"}
return next((f"{full_name}{intf_id}" for alias, full_name in alias_map.items() if v.lower().startswith(alias)), v)
@@ -81,26 +75,57 @@ def interface_case_sensitivity(v: str) -> str:
def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str:
"""Abbreviations for different BGP multiprotocol capabilities.
+ Handles different separators (hyphen, underscore, space) and case sensitivity.
+
Examples
--------
- - IPv4 Unicast
- - L2vpnEVPN
- - ipv4 MPLS Labels
- - ipv4Mplsvpn
-
+ ```python
+ >>> bgp_multiprotocol_capabilities_abbreviations("IPv4 Unicast")
+ 'ipv4Unicast'
+ >>> bgp_multiprotocol_capabilities_abbreviations("ipv4-Flow_Spec Vpn")
+ 'ipv4FlowSpecVpn'
+ >>> bgp_multiprotocol_capabilities_abbreviations("ipv6_labeled-unicast")
+ 'ipv6MplsLabels'
+ >>> bgp_multiprotocol_capabilities_abbreviations("ipv4_mpls_vpn")
+ 'ipv4MplsVpn'
+ >>> bgp_multiprotocol_capabilities_abbreviations("ipv4 mpls labels")
+ 'ipv4MplsLabels'
+ >>> bgp_multiprotocol_capabilities_abbreviations("rt-membership")
+ 'rtMembership'
+ >>> bgp_multiprotocol_capabilities_abbreviations("dynamic-path-selection")
+ 'dps'
+ ```
"""
patterns = {
- REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn",
- REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels",
- REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn",
- REGEX_BGP_IPV4_UNICAST: "ipv4Unicast",
+ f"{r'dynamic[-_ ]?path[-_ ]?selection$'}": "dps",
+ f"{r'dps$'}": "dps",
+ f"{REGEX_IPV4_UNICAST}": "ipv4Unicast",
+ f"{REGEX_IPV6_UNICAST}": "ipv6Unicast",
+ f"{REGEX_IPV4_MULTICAST}": "ipv4Multicast",
+ f"{REGEX_IPV6_MULTICAST}": "ipv6Multicast",
+ f"{r'ipv4[-_ ]?labeled[-_ ]?Unicast$'}": "ipv4MplsLabels",
+ f"{r'ipv4[-_ ]?mpls[-_ ]?labels$'}": "ipv4MplsLabels",
+ f"{r'ipv6[-_ ]?labeled[-_ ]?Unicast$'}": "ipv6MplsLabels",
+ f"{r'ipv6[-_ ]?mpls[-_ ]?labels$'}": "ipv6MplsLabels",
+ f"{r'ipv4[-_ ]?sr[-_ ]?te$'}": "ipv4SrTe", # codespell:ignore
+ f"{r'ipv6[-_ ]?sr[-_ ]?te$'}": "ipv6SrTe", # codespell:ignore
+ f"{r'ipv4[-_ ]?mpls[-_ ]?vpn$'}": "ipv4MplsVpn",
+ f"{r'ipv6[-_ ]?mpls[-_ ]?vpn$'}": "ipv6MplsVpn",
+ f"{r'ipv4[-_ ]?Flow[-_ ]?spec$'}": "ipv4FlowSpec",
+ f"{r'ipv6[-_ ]?Flow[-_ ]?spec$'}": "ipv6FlowSpec",
+ f"{r'ipv4[-_ ]?Flow[-_ ]?spec[-_ ]?vpn$'}": "ipv4FlowSpecVpn",
+ f"{r'ipv6[-_ ]?Flow[-_ ]?spec[-_ ]?vpn$'}": "ipv6FlowSpecVpn",
+ f"{r'l2[-_ ]?vpn[-_ ]?vpls$'}": "l2VpnVpls",
+ f"{r'l2[-_ ]?vpn[-_ ]?evpn$'}": "l2VpnEvpn",
+ f"{r'link[-_ ]?state$'}": "linkState",
+ f"{r'rt[-_ ]?membership$'}": "rtMembership",
+ f"{r'ipv4[-_ ]?rt[-_ ]?membership$'}": "rtMembership",
+ f"{r'ipv4[-_ ]?mvpn$'}": "ipv4Mvpn",
}
-
for pattern, replacement in patterns.items():
- match = re.search(pattern, value, re.IGNORECASE)
+ match = re.match(pattern, value, re.IGNORECASE)
if match:
return replacement
-
return value
@@ -114,6 +139,54 @@ def validate_regex(value: str) -> str:
return value
+def bgp_redistributed_route_proto_abbreviations(value: str) -> str:
+ """Abbreviations for different BGP redistributed route protocols.
+
+ Handles different separators (hyphen, underscore, space) and case sensitivity.
+
+ Examples
+ --------
+ ```python
+ >>> bgp_redistributed_route_proto_abbreviations("IPv4 Unicast")
+ 'v4u'
+ >>> bgp_redistributed_route_proto_abbreviations("IPv4-multicast")
+ 'v4m'
+ >>> bgp_redistributed_route_proto_abbreviations("IPv6_multicast")
+ 'v6m'
+ >>> bgp_redistributed_route_proto_abbreviations("ipv6unicast")
+ 'v6u'
+ ```
+ """
+ patterns = {REGEX_IPV4_UNICAST: "v4u", REGEX_IPV4_MULTICAST: "v4m", REGEX_IPV6_UNICAST: "v6u", REGEX_IPV6_MULTICAST: "v6m"}
+
+ for pattern, replacement in patterns.items():
+ match = re.match(pattern, value, re.IGNORECASE)
+ if match:
+ return replacement
+
+ return value
+
+
+def update_bgp_redistributed_proto_user(value: str) -> str:
+ """Update BGP redistributed route `User` proto with EOS SDK.
+
+ Examples
+ --------
+ ```python
+ >>> update_bgp_redistributed_proto_user("User")
+ 'EOS SDK'
+ >>> update_bgp_redistributed_proto_user("Bgp")
+ 'Bgp'
+ >>> update_bgp_redistributed_proto_user("RIP")
+ 'RIP'
+ ```
+ """
+ if value == "User":
+ value = "EOS SDK"
+
+ return value
+
+
# AntaTest.Input types
AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)]
Vlan = Annotated[int, Field(ge=0, le=4094)]
@@ -148,22 +221,68 @@ Safi = Literal["unicast", "multicast", "labeled-unicast", "sr-te"]
EncryptionAlgorithm = Literal["RSA", "ECDSA"]
RsaKeySize = Literal[2048, 3072, 4096]
EcdsaKeySize = Literal[256, 384, 512]
-MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)]
+MultiProtocolCaps = Annotated[
+ Literal[
+ "dps",
+ "ipv4Unicast",
+ "ipv6Unicast",
+ "ipv4Multicast",
+ "ipv6Multicast",
+ "ipv4MplsLabels",
+ "ipv6MplsLabels",
+ "ipv4SrTe",
+ "ipv6SrTe",
+ "ipv4MplsVpn",
+ "ipv6MplsVpn",
+ "ipv4FlowSpec",
+ "ipv6FlowSpec",
+ "ipv4FlowSpecVpn",
+ "ipv6FlowSpecVpn",
+ "l2VpnVpls",
+ "l2VpnEvpn",
+ "linkState",
+ "rtMembership",
+ "ipv4Mvpn",
+ ],
+ BeforeValidator(bgp_multiprotocol_capabilities_abbreviations),
+]
BfdInterval = Annotated[int, Field(ge=50, le=60000)]
BfdMultiplier = Annotated[int, Field(ge=3, le=50)]
ErrDisableReasons = Literal[
"acl",
"arp-inspection",
+ "bgp-session-tracking",
"bpduguard",
+ "dot1x",
+ "dot1x-coa",
"dot1x-session-replace",
+ "evpn-sa-mh",
+ "fabric-link-failure",
+ "fabric-link-flap",
"hitless-reload-down",
+ "lacp-no-portid",
"lacp-rate-limit",
+ "license-enforce",
"link-flap",
+ "mlagasu",
+ "mlagdualprimary",
+ "mlagissu",
+ "mlagmaintdown",
"no-internal-vlan",
+ "out-of-voqs",
"portchannelguard",
+ "portgroup-disabled",
"portsec",
+ "speed-misconfigured",
+ "storm-control",
+ "stp-no-portid",
+ "stuck-queue",
"tapagg",
"uplink-failure-detection",
+ "xcvr-misconfigured",
+ "xcvr-overheat",
+ "xcvr-power-unsupported",
+ "xcvr-unsupported",
]
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
@@ -204,11 +323,6 @@ BgpDropStats = Literal[
]
BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"]
BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"]
-SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"]
-SnmpErrorCounter = Literal[
- "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
-]
-
IPv4RouteType = Literal[
"connected",
"static",
@@ -238,3 +352,47 @@ IPv4RouteType = Literal[
"Route Cache Route",
"CBF Leaked Route",
]
+DynamicVlanSource = Literal["dmf", "dot1x", "dynvtep", "evpn", "mlag", "mlagsync", "mvpn", "swfwd", "vccbfd"]
+LogSeverityLevel = Literal["alerts", "critical", "debugging", "emergencies", "errors", "informational", "notifications", "warnings"]
+
+
+########################################
+# SNMP
+########################################
+def snmp_v3_prefix(auth_type: Literal["auth", "priv", "noauth"]) -> str:
+ """Prefix the SNMP authentication type with 'v3'."""
+ if auth_type == "noauth":
+ return "v3NoAuth"
+ return f"v3{auth_type.title()}"
+
+
+SnmpVersion = Literal["v1", "v2c", "v3"]
+SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"]
+SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"]
+SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"]
+SnmpErrorCounter = Literal[
+ "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
+]
+SnmpVersionV3AuthType = Annotated[Literal["auth", "priv", "noauth"], AfterValidator(snmp_v3_prefix)]
+RedistributedProtocol = Annotated[
+ Literal[
+ "AttachedHost",
+ "Bgp",
+ "Connected",
+ "DHCP",
+ "Dynamic",
+ "IS-IS",
+ "OSPF Internal",
+ "OSPF External",
+ "OSPF Nssa-External",
+ "OSPFv3 Internal",
+ "OSPFv3 External",
+ "OSPFv3 Nssa-External",
+ "RIP",
+ "Static",
+ "User",
+ ],
+ AfterValidator(update_bgp_redistributed_proto_user),
+]
+RedistributedAfiSafi = Annotated[Literal["v4u", "v4m", "v6u", "v6m"], BeforeValidator(bgp_redistributed_route_proto_abbreviations)]
+NTPStratumLevel = Annotated[int, Field(ge=0, le=16)]
diff --git a/anta/decorators.py b/anta/decorators.py
index 0431623..0ca2be8 100644
--- a/anta/decorators.py
+++ b/anta/decorators.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""decorators for tests."""
@@ -58,7 +58,7 @@ def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: #
logger.warning("%s test is deprecated.", anta_test.name)
return await function(*args, **kwargs)
- return cast(F, wrapper)
+ return cast("F", wrapper)
return decorator
@@ -167,6 +167,6 @@ def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
return await function(*args, **kwargs)
- return cast(F, wrapper)
+ return cast("F", wrapper)
return decorator
diff --git a/anta/device.py b/anta/device.py
index 561323f..7c1e6f6 100644
--- a/anta/device.py
+++ b/anta/device.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""ANTA Device Abstraction Module."""
@@ -8,13 +8,12 @@ from __future__ import annotations
import asyncio
import logging
from abc import ABC, abstractmethod
-from collections import defaultdict
+from collections import OrderedDict, defaultdict
+from time import monotonic
from typing import TYPE_CHECKING, Any, Literal
import asyncssh
import httpcore
-from aiocache import Cache
-from aiocache.plugins import HitMissRatioPlugin
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
from httpx import ConnectError, HTTPError, TimeoutException
@@ -27,12 +26,79 @@ if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
+ from asynceapi._types import EapiComplexCommand, EapiSimpleCommand
+
logger = logging.getLogger(__name__)
# Do not load the default keypairs multiple times due to a performance issue introduced in cryptography 37.0
# https://github.com/pyca/cryptography/issues/7236#issuecomment-1131908472
CLIENT_KEYS = asyncssh.public_key.load_default_keypairs()
+# Limit concurrency to 100 requests (HTTPX default) to avoid high-concurrency performance issues
+# See: https://github.com/encode/httpx/issues/3215
+MAX_CONCURRENT_REQUESTS = 100
+
+
+class AntaCache:
+ """Class to be used as cache.
+
+ Example
+ -------
+
+ ```python
+ # Create cache
+ cache = AntaCache("device1")
+ with cache.locks[key]:
+ command_output = cache.get(key)
+ ```
+ """
+
+ def __init__(self, device: str, max_size: int = 128, ttl: int = 60) -> None:
+ """Initialize the cache."""
+ self.device = device
+ self.cache: OrderedDict[str, Any] = OrderedDict()
+ self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
+ self.max_size = max_size
+ self.ttl = ttl
+
+ # Stats
+ self.stats: dict[str, int] = {}
+ self._init_stats()
+
+ def _init_stats(self) -> None:
+ """Initialize the stats."""
+ self.stats["hits"] = 0
+ self.stats["total"] = 0
+
+ async def get(self, key: str) -> Any: # noqa: ANN401
+ """Return the cached entry for key."""
+ self.stats["total"] += 1
+ if key in self.cache:
+ timestamp, value = self.cache[key]
+ if monotonic() - timestamp < self.ttl:
+ # checking the value is still valid
+ self.cache.move_to_end(key)
+ self.stats["hits"] += 1
+ return value
+ # Time expired
+ del self.cache[key]
+ del self.locks[key]
+ return None
+
+ async def set(self, key: str, value: Any) -> bool: # noqa: ANN401
+ """Set the cached entry for key to value."""
+ timestamp = monotonic()
+ if len(self.cache) > self.max_size:
+ self.cache.popitem(last=False)
+ self.cache[key] = timestamp, value
+ return True
+
+ def clear(self) -> None:
+ """Empty the cache."""
+ logger.debug("Clearing cache for device %s", self.device)
+ self.cache = OrderedDict()
+ self._init_stats()
+
class AntaDevice(ABC):
"""Abstract class representing a device in ANTA.
@@ -52,10 +118,11 @@ class AntaDevice(ABC):
Hardware model of the device.
tags : set[str]
Tags for this device.
- cache : Cache | None
- In-memory cache from aiocache library for this device (None if cache is disabled).
+ cache : AntaCache | None
+ In-memory cache for this device (None if cache is disabled).
cache_locks : dict
Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
+ Deprecated, will be removed in ANTA v2.0.0, use self.cache.locks instead.
"""
@@ -79,7 +146,8 @@ class AntaDevice(ABC):
self.tags.add(self.name)
self.is_online: bool = False
self.established: bool = False
- self.cache: Cache | None = None
+ self.cache: AntaCache | None = None
+ # Keeping cache_locks for backward compatibility.
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None
# Initialize cache if not disabled
@@ -101,17 +169,16 @@ class AntaDevice(ABC):
def _init_cache(self) -> None:
"""Initialize cache for the device, can be overridden by subclasses to manipulate how it works."""
- self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
- self.cache_locks = defaultdict(asyncio.Lock)
+ self.cache = AntaCache(device=self.name, ttl=60)
+ self.cache_locks = self.cache.locks
@property
def cache_statistics(self) -> dict[str, Any] | None:
"""Return the device cache statistics for logging purposes."""
- # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
- # https://github.com/pylint-dev/pylint/issues/7258
if self.cache is not None:
- stats = getattr(self.cache, "hit_miss_ratio", {"total": 0, "hits": 0, "hit_ratio": 0})
- return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{stats['hit_ratio'] * 100:.2f}%"}
+ stats = self.cache.stats
+ ratio = stats["hits"] / stats["total"] if stats["total"] > 0 else 0
+ return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{ratio * 100:.2f}%"}
return None
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
@@ -177,18 +244,16 @@ class AntaDevice(ABC):
collection_id
An identifier used to build the eAPI request ID.
"""
- # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
- # https://github.com/pylint-dev/pylint/issues/7258
- if self.cache is not None and self.cache_locks is not None and command.use_cache:
- async with self.cache_locks[command.uid]:
- cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
+ if self.cache is not None and command.use_cache:
+ async with self.cache.locks[command.uid]:
+ cached_output = await self.cache.get(command.uid)
if cached_output is not None:
logger.debug("Cache hit for %s on %s", command.command, self.name)
command.output = cached_output
else:
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)
else:
await self._collect(command=command, collection_id=collection_id)
@@ -237,6 +302,7 @@ class AntaDevice(ABC):
raise NotImplementedError(msg)
+# pylint: disable=too-many-instance-attributes
class AsyncEOSDevice(AntaDevice):
"""Implementation of AntaDevice for EOS using aio-eapi.
@@ -329,6 +395,10 @@ class AsyncEOSDevice(AntaDevice):
host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params
)
+ # In Python 3.9, Semaphore must be created within a running event loop
+ # TODO: Once we drop Python 3.9 support, initialize the semaphore here
+ self._command_semaphore: asyncio.Semaphore | None = None
+
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
"""Implement Rich Repr Protocol.
@@ -372,6 +442,15 @@ class AsyncEOSDevice(AntaDevice):
"""
return (self._session.host, self._session.port)
+ async def _get_semaphore(self) -> asyncio.Semaphore:
+ """Return the semaphore, initializing it if needed.
+
+ TODO: Remove this method once we drop Python 3.9 support.
+ """
+ if self._command_semaphore is None:
+ self._command_semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
+ return self._command_semaphore
+
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
"""Collect device command output from EOS using aio-eapi.
@@ -386,57 +465,63 @@ class AsyncEOSDevice(AntaDevice):
collection_id
An identifier used to build the eAPI request ID.
"""
- commands: list[dict[str, str | int]] = []
- if self.enable and self._enable_password is not None:
- commands.append(
- {
- "cmd": "enable",
- "input": str(self._enable_password),
- },
- )
- elif self.enable:
- # No password
- commands.append({"cmd": "enable"})
- commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
- try:
- response: list[dict[str, Any] | str] = await self._session.cli(
- commands=commands,
- ofmt=command.ofmt,
- version=command.version,
- req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}",
- ) # type: ignore[assignment] # multiple commands returns a list
- # Do not keep response of 'enable' command
- command.output = response[-1]
- except asynceapi.EapiCommandError as e:
- # This block catches exceptions related to EOS issuing an error.
- self._log_eapi_command_error(command, e)
- except TimeoutException as e:
- # This block catches Timeout exceptions.
- command.errors = [exc_to_str(e)]
- timeouts = self._session.timeout.as_dict()
- logger.error(
- "%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s",
- exc_to_str(e),
- self.name,
- timeouts["connect"],
- timeouts["read"],
- timeouts["write"],
- timeouts["pool"],
- )
- except (ConnectError, OSError) as e:
- # This block catches OSError and socket issues related exceptions.
- command.errors = [exc_to_str(e)]
- if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance(os_error := e, OSError): # pylint: disable=no-member
- if isinstance(os_error.__cause__, OSError):
- os_error = os_error.__cause__
- logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error)
- else:
+ semaphore = await self._get_semaphore()
+
+ async with semaphore:
+ commands: list[EapiComplexCommand | EapiSimpleCommand] = []
+ if self.enable and self._enable_password is not None:
+ commands.append(
+ {
+ "cmd": "enable",
+ "input": str(self._enable_password),
+ },
+ )
+ elif self.enable:
+ # No password
+ commands.append({"cmd": "enable"})
+ commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}]
+ try:
+ response = await self._session.cli(
+ commands=commands,
+ ofmt=command.ofmt,
+ version=command.version,
+ req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}",
+ )
+ # Do not keep response of 'enable' command
+ command.output = response[-1]
+ except asynceapi.EapiCommandError as e:
+ # This block catches exceptions related to EOS issuing an error.
+ self._log_eapi_command_error(command, e)
+ except TimeoutException as e:
+ # This block catches Timeout exceptions.
+ command.errors = [exc_to_str(e)]
+ timeouts = self._session.timeout.as_dict()
+ logger.error(
+ "%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s",
+ exc_to_str(e),
+ self.name,
+ timeouts["connect"],
+ timeouts["read"],
+ timeouts["write"],
+ timeouts["pool"],
+ )
+ except (ConnectError, OSError) as e:
+ # This block catches OSError and socket issues related exceptions.
+ command.errors = [exc_to_str(e)]
+ # pylint: disable=no-member
+ if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance(
+ os_error := e, OSError
+ ):
+ if isinstance(os_error.__cause__, OSError):
+ os_error = os_error.__cause__
+ logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error)
+ else:
+ anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
+ except HTTPError as e:
+ # This block catches most of the httpx Exceptions and logs a general message.
+ command.errors = [exc_to_str(e)]
anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
- except HTTPError as e:
- # This block catches most of the httpx Exceptions and logs a general message.
- command.errors = [exc_to_str(e)]
- anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
- logger.debug("%s: %s", self.name, command)
+ logger.debug("%s: %s", self.name, command)
def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None:
"""Appropriately log the eapi command error."""
diff --git a/anta/input_models/__init__.py b/anta/input_models/__init__.py
index 5b8974c..5dbf827 100644
--- a/anta/input_models/__init__.py
+++ b/anta/input_models/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Package related to all ANTA tests input models."""
diff --git a/anta/input_models/avt.py b/anta/input_models/avt.py
index 9219c2f..44fd780 100644
--- a/anta/input_models/avt.py
+++ b/anta/input_models/avt.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for AVT tests."""
@@ -33,4 +33,4 @@ class AVTPath(BaseModel):
AVT CONTROL-PLANE-PROFILE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1)
"""
- return f"AVT {self.avt_name} VRF: {self.vrf} (Destination: {self.destination}, Next-hop: {self.next_hop})"
+ return f"AVT: {self.avt_name} VRF: {self.vrf} Destination: {self.destination} Next-hop: {self.next_hop}"
diff --git a/anta/input_models/bfd.py b/anta/input_models/bfd.py
index 9ccc625..06838d0 100644
--- a/anta/input_models/bfd.py
+++ b/anta/input_models/bfd.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for BFD tests."""
@@ -31,6 +31,10 @@ class BFDPeer(BaseModel):
"""Multiplier of BFD peer. Required field in the `VerifyBFDPeersIntervals` test."""
protocols: list[BfdProtocol] | None = None
"""List of protocols to be verified. Required field in the `VerifyBFDPeersRegProtocols` test."""
+ detection_time: int | None = None
+ """Detection time of BFD peer in milliseconds. Defines how long to wait without receiving BFD packets before declaring the peer session as down.
+
+ Optional field in the `VerifyBFDPeersIntervals` test."""
def __str__(self) -> str:
"""Return a human-readable string representation of the BFDPeer for reporting."""
diff --git a/anta/input_models/connectivity.py b/anta/input_models/connectivity.py
index e8f5553..464d22a 100644
--- a/anta/input_models/connectivity.py
+++ b/anta/input_models/connectivity.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for connectivity tests."""
from __future__ import annotations
-from ipaddress import IPv4Address
+from ipaddress import IPv4Address, IPv6Address
from typing import Any
from warnings import warn
@@ -18,29 +18,30 @@ class Host(BaseModel):
"""Model for a remote host to ping."""
model_config = ConfigDict(extra="forbid")
- destination: IPv4Address
- """IPv4 address to ping."""
- source: IPv4Address | Interface
- """IPv4 address source IP or egress interface to use."""
+ destination: IPv4Address | IPv6Address
+ """Destination address to ping."""
+ source: IPv4Address | IPv6Address | Interface
+ """Source address IP or egress interface to use."""
vrf: str = "default"
- """VRF context. Defaults to `default`."""
+ """VRF context."""
repeat: int = 2
- """Number of ping repetition. Defaults to 2."""
+ """Number of ping repetition."""
size: int = 100
- """Specify datagram size. Defaults to 100."""
+ """Specify datagram size."""
df_bit: bool = False
- """Enable do not fragment bit in IP header. Defaults to False."""
+ """Enable do not fragment bit in IP header."""
+ reachable: bool = True
+ """Indicates whether the destination should be reachable."""
def __str__(self) -> str:
"""Return a human-readable string representation of the Host for reporting.
Examples
--------
- Host 10.1.1.1 (src: 10.2.2.2, vrf: mgmt, size: 100B, repeat: 2)
+ Host: 10.1.1.1 Source: 10.2.2.2 VRF: mgmt
"""
- df_status = ", df-bit: enabled" if self.df_bit else ""
- return f"Host {self.destination} (src: {self.source}, vrf: {self.vrf}, size: {self.size}B, repeat: {self.repeat}{df_status})"
+ return f"Host: {self.destination} Source: {self.source} VRF: {self.vrf}"
class LLDPNeighbor(BaseModel):
@@ -59,10 +60,10 @@ class LLDPNeighbor(BaseModel):
Examples
--------
- Port Ethernet1 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet2)
+ Port: Ethernet1 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet2
"""
- return f"Port {self.port} (Neighbor: {self.neighbor_device}, Neighbor Port: {self.neighbor_port})"
+ return f"Port: {self.port} Neighbor: {self.neighbor_device} Neighbor Port: {self.neighbor_port}"
class Neighbor(LLDPNeighbor): # pragma: no cover
diff --git a/anta/input_models/cvx.py b/anta/input_models/cvx.py
index 4f93749..e2f5f8e 100644
--- a/anta/input_models/cvx.py
+++ b/anta/input_models/cvx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for CVX tests."""
diff --git a/anta/input_models/flow_tracking.py b/anta/input_models/flow_tracking.py
new file mode 100644
index 0000000..5f4c25b
--- /dev/null
+++ b/anta/input_models/flow_tracking.py
@@ -0,0 +1,72 @@
+# Copyright (c) 2023-2025 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 containing input models for flow tracking tests."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict
+
+
+class FlowTracker(BaseModel):
+ """Flow Tracking model representing the tracker details."""
+
+ model_config = ConfigDict(extra="forbid")
+ name: str
+ """The name of the flow tracker."""
+ record_export: RecordExport | None = None
+ """Configuration for record export, specifying details about timeouts."""
+ exporters: list[Exporter] | None = None
+ """A list of exporters associated with the flow tracker."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the FlowTracker for reporting.
+
+ Examples
+ --------
+ Flow Tracker: FLOW-TRACKER
+
+ """
+ return f"Flow Tracker: {self.name}"
+
+
+class RecordExport(BaseModel):
+ """Model representing the record export configuration for a flow tracker."""
+
+ model_config = ConfigDict(extra="forbid")
+ on_inactive_timeout: int
+ """The timeout in milliseconds for exporting flow records when the flow becomes inactive."""
+ on_interval: int
+ """The interval in milliseconds for exporting flow records."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the RecordExport for reporting.
+
+ Examples
+ --------
+ Inactive Timeout: 60000, Active Interval: 300000
+
+ """
+ return f"Inactive Timeout: {self.on_inactive_timeout} Active Interval: {self.on_interval}"
+
+
+class Exporter(BaseModel):
+ """Model representing the exporter used for flow record export."""
+
+ model_config = ConfigDict(extra="forbid")
+ name: str
+ """The name of the exporter."""
+ local_interface: str
+ """The local interface used by the exporter to send flow records."""
+ template_interval: int
+ """The template interval, in milliseconds, for the exporter to refresh the flow template."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the Exporter for reporting.
+
+ Examples
+ --------
+ Exporter: CVP-TELEMETRY
+
+ """
+ return f"Exporter: {self.name}"
diff --git a/anta/input_models/interfaces.py b/anta/input_models/interfaces.py
index 9e33a2c..c9ecedb 100644
--- a/anta/input_models/interfaces.py
+++ b/anta/input_models/interfaces.py
@@ -1,19 +1,24 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for interface tests."""
from __future__ import annotations
-from typing import Literal
+from ipaddress import IPv4Interface
+from typing import Any, Literal
+from warnings import warn
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, Field
from anta.custom_types import Interface, PortChannelInterface
class InterfaceState(BaseModel):
- """Model for an interface state."""
+ """Model for an interface state.
+
+ TODO: Need to review this class name in ANTA v2.0.0.
+ """
model_config = ConfigDict(extra="forbid")
name: Interface
@@ -33,6 +38,16 @@ class InterfaceState(BaseModel):
Can be enabled in the `VerifyLACPInterfacesStatus` tests.
"""
+ primary_ip: IPv4Interface | None = None
+ """Primary IPv4 address in CIDR notation. Required field in the `VerifyInterfaceIPv4` test."""
+ secondary_ips: list[IPv4Interface] | None = None
+ """List of secondary IPv4 addresses in CIDR notation. Can be provided in the `VerifyInterfaceIPv4` test."""
+ auto: bool = False
+ """The auto-negotiation status of the interface. Can be provided in the `VerifyInterfacesSpeed` test."""
+ speed: float | None = Field(None, ge=1, le=1000)
+ """The speed of the interface in Gigabits per second. Valid range is 1 to 1000. Required field in the `VerifyInterfacesSpeed` test."""
+ lanes: int | None = Field(None, ge=1, le=8)
+ """The number of lanes in the interface. Valid range is 1 to 8. Can be provided in the `VerifyInterfacesSpeed` test."""
def __str__(self) -> str:
"""Return a human-readable string representation of the InterfaceState for reporting.
@@ -46,3 +61,21 @@ class InterfaceState(BaseModel):
if self.portchannel is not None:
base_string += f" Port-Channel: {self.portchannel}"
return base_string
+
+
+class InterfaceDetail(InterfaceState): # pragma: no cover
+ """Alias for the InterfaceState model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the InterfaceState model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the InterfaceState class, emitting a depreciation warning."""
+ warn(
+ message="InterfaceDetail model is deprecated and will be removed in ANTA v2.0.0. Use the InterfaceState model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
diff --git a/anta/input_models/logging.py b/anta/input_models/logging.py
new file mode 100644
index 0000000..977f1ab
--- /dev/null
+++ b/anta/input_models/logging.py
@@ -0,0 +1,21 @@
+# Copyright (c) 2023-2025 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 containing input models for logging tests."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+from anta.custom_types import LogSeverityLevel, RegexString
+
+
+class LoggingQuery(BaseModel):
+ """Logging query model representing the logging details."""
+
+ regex_match: RegexString
+ """Log regex pattern to be searched in last log entries."""
+ last_number_messages: int = Field(ge=1, le=9999)
+ """Last number of messages to check in the logging buffers."""
+ severity_level: LogSeverityLevel = "informational"
+ """Log severity level."""
diff --git a/anta/input_models/path_selection.py b/anta/input_models/path_selection.py
new file mode 100644
index 0000000..cf06c90
--- /dev/null
+++ b/anta/input_models/path_selection.py
@@ -0,0 +1,28 @@
+# Copyright (c) 2023-2025 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 containing input models for path-selection tests."""
+
+from __future__ import annotations
+
+from ipaddress import IPv4Address
+
+from pydantic import BaseModel, ConfigDict
+
+
+class DpsPath(BaseModel):
+ """Model for a list of DPS path entries."""
+
+ model_config = ConfigDict(extra="forbid")
+ 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 __str__(self) -> str:
+ """Return a human-readable string representation of the DpsPath for reporting."""
+ return f"Peer: {self.peer} PathGroup: {self.path_group} Source: {self.source_address} Destination: {self.destination_address}"
diff --git a/anta/input_models/routing/__init__.py b/anta/input_models/routing/__init__.py
index e1188cc..772b4f9 100644
--- a/anta/input_models/routing/__init__.py
+++ b/anta/input_models/routing/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Package related to routing tests input models."""
diff --git a/anta/input_models/routing/bgp.py b/anta/input_models/routing/bgp.py
index 57c8217..09def7f 100644
--- a/anta/input_models/routing/bgp.py
+++ b/anta/input_models/routing/bgp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for routing BGP tests."""
@@ -6,13 +6,13 @@
from __future__ import annotations
from ipaddress import IPv4Address, IPv4Network, IPv6Address
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Literal
from warnings import warn
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
from pydantic_extra_types.mac_address import MacAddress
-from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, Safi, Vni
+from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, RedistributedAfiSafi, RedistributedProtocol, Safi, Vni
if TYPE_CHECKING:
import sys
@@ -39,6 +39,23 @@ AFI_SAFI_EOS_KEY = {
("link-state", None): "linkState",
}
"""Dictionary mapping AFI/SAFI to EOS key representation."""
+AFI_SAFI_MAPPINGS = {"v4u": "IPv4 Unicast", "v4m": "IPv4 Multicast", "v6u": "IPv6 Unicast", "v6m": "IPv6 Multicast"}
+"""Dictionary mapping AFI/SAFI to EOS key representation for BGP redistributed route protocol."""
+IPV4_MULTICAST_SUPPORTED_PROTO = [
+ "AttachedHost",
+ "Connected",
+ "IS-IS",
+ "OSPF Internal",
+ "OSPF External",
+ "OSPF Nssa-External",
+ "OSPFv3 Internal",
+ "OSPFv3 External",
+ "OSPFv3 Nssa-External",
+ "Static",
+]
+"""List of BGP redistributed route protocol, supported for IPv4 multicast address family."""
+IPV6_MULTICAST_SUPPORTED_PROTO = [proto for proto in IPV4_MULTICAST_SUPPORTED_PROTO if proto != "AttachedHost"]
+"""List of BGP redistributed route protocol, supported for IPv6 multicast address family."""
class BgpAddressFamily(BaseModel):
@@ -68,8 +85,7 @@ class BgpAddressFamily(BaseModel):
check_peer_state: bool = False
"""Flag to check if the peers are established with negotiated AFI/SAFI. Defaults to `False`.
- Can be enabled in the `VerifyBGPPeerCount` tests.
- """
+ Can be enabled in the `VerifyBGPPeerCount` tests."""
@model_validator(mode="after")
def validate_inputs(self) -> Self:
@@ -142,12 +158,14 @@ class BgpPeer(BaseModel):
"""IPv4 address of the BGP peer."""
vrf: str = "default"
"""Optional VRF for the BGP peer. Defaults to `default`."""
+ peer_group: str | None = None
+ """Peer group of the BGP peer. Required field in the `VerifyBGPPeerGroup` test."""
advertised_routes: list[IPv4Network] | None = None
"""List of advertised routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test."""
received_routes: list[IPv4Network] | None = None
"""List of received routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test."""
capabilities: list[MultiProtocolCaps] | None = None
- """List of BGP multiprotocol capabilities. Required field in the `VerifyBGPPeerMPCaps` test."""
+ """List of BGP multiprotocol capabilities. Required field in the `VerifyBGPPeerMPCaps`, `VerifyBGPNlriAcceptance` tests."""
strict: bool = False
"""If True, requires exact match of the provided BGP multiprotocol capabilities.
@@ -169,9 +187,15 @@ class BgpPeer(BaseModel):
outbound_route_map: str | None = None
"""Outbound route map applied, defaults to None. Required field in the `VerifyBgpRouteMaps` test."""
maximum_routes: int | None = Field(default=None, ge=0, le=4294967294)
- """The maximum allowable number of BGP routes, `0` means unlimited. Required field in the `VerifyBGPPeerRouteLimit` test"""
+ """The maximum allowable number of BGP routes. `0` means unlimited. Required field in the `VerifyBGPPeerRouteLimit` test"""
warning_limit: int | None = Field(default=None, ge=0, le=4294967294)
- """Optional maximum routes warning limit. If not provided, it defaults to `0` meaning no warning limit."""
+ """The warning limit for the maximum routes. `0` means no warning.
+
+ Optional field in the `VerifyBGPPeerRouteLimit` test. If not provided, the test will not verify the warning limit."""
+ ttl: int | None = Field(default=None, ge=1, le=255)
+ """The Time-To-Live (TTL). Required field in the `VerifyBGPPeerTtlMultiHops` test."""
+ max_ttl_hops: int | None = Field(default=None, ge=1, le=255)
+ """The Max TTL hops. Required field in the `VerifyBGPPeerTtlMultiHops` test."""
def __str__(self) -> str:
"""Return a human-readable string representation of the BgpPeer for reporting."""
@@ -207,3 +231,159 @@ class VxlanEndpoint(BaseModel):
def __str__(self) -> str:
"""Return a human-readable string representation of the VxlanEndpoint for reporting."""
return f"Address: {self.address} VNI: {self.vni}"
+
+
+class BgpRoute(BaseModel):
+ """Model representing BGP routes.
+
+ Only IPv4 prefixes are supported for now.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+ prefix: IPv4Network
+ """The IPv4 network address."""
+ vrf: str = "default"
+ """Optional VRF for the BGP peer. Defaults to `default`."""
+ paths: list[BgpRoutePath] | None = None
+ """A list of paths for the BGP route. Required field in the `VerifyBGPRoutePaths` test."""
+ ecmp_count: int | None = None
+ """The expected number of ECMP paths for the BGP route. Required field in the `VerifyBGPRouteECMP` test."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the BgpRoute for reporting.
+
+ Examples
+ --------
+ - Prefix: 192.168.66.100/24 VRF: default
+ """
+ return f"Prefix: {self.prefix} VRF: {self.vrf}"
+
+
+class BgpRoutePath(BaseModel):
+ """Model representing a BGP route path."""
+
+ model_config = ConfigDict(extra="forbid")
+ nexthop: IPv4Address
+ """The next-hop IPv4 address for the path."""
+ origin: Literal["Igp", "Egp", "Incomplete"]
+ """The BGP origin attribute of the route."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the RoutePath for reporting.
+
+ Examples
+ --------
+ - Next-hop: 192.168.66.101 Origin: Igp
+ """
+ return f"Next-hop: {self.nexthop} Origin: {self.origin}"
+
+
+class BgpVrf(BaseModel):
+ """Model representing a VRF in a BGP instance."""
+
+ vrf: str = "default"
+ """VRF context."""
+ address_families: list[AddressFamilyConfig]
+ """List of address family configuration."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the BgpVrf for reporting.
+
+ Examples
+ --------
+ - VRF: default
+ """
+ return f"VRF: {self.vrf}"
+
+
+class RedistributedRouteConfig(BaseModel):
+ """Model representing a BGP redistributed route configuration."""
+
+ proto: RedistributedProtocol
+ """The redistributed protocol."""
+ include_leaked: bool = False
+ """Flag to include leaked routes of the redistributed protocol while redistributing."""
+ route_map: str | None = None
+ """Optional route map applied to the redistribution."""
+
+ @model_validator(mode="after")
+ def validate_inputs(self) -> Self:
+ """Validate that 'include_leaked' is not set when the redistributed protocol is AttachedHost, User, Dynamic, or RIP."""
+ if self.include_leaked and self.proto in ["AttachedHost", "EOS SDK", "Dynamic", "RIP"]:
+ msg = f"'include_leaked' field is not supported for redistributed protocol '{self.proto}'"
+ raise ValueError(msg)
+ return self
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the RedistributedRouteConfig for reporting.
+
+ Examples
+ --------
+ - Proto: Connected, Include Leaked: True, Route Map: RM-CONN-2-BGP
+ """
+ base_string = f"Proto: {self.proto}"
+ if self.include_leaked:
+ base_string += f", Include Leaked: {self.include_leaked}"
+ if self.route_map:
+ base_string += f", Route Map: {self.route_map}"
+ return base_string
+
+
+class AddressFamilyConfig(BaseModel):
+ """Model representing a BGP address family configuration."""
+
+ afi_safi: RedistributedAfiSafi
+ """AFI/SAFI abbreviation per EOS."""
+ redistributed_routes: list[RedistributedRouteConfig]
+ """List of redistributed route configuration."""
+
+ @model_validator(mode="after")
+ def validate_afi_safi_supported_routes(self) -> Self:
+ """Validate each address family supported redistributed protocol.
+
+ Following table shows the supported redistributed routes for each address family.
+
+ | IPv4 Unicast | IPv6 Unicast | IPv4 Multicast | IPv6 Multicast |
+ | ------------------------|-------------------------|------------------------|------------------------|
+ | AttachedHost | AttachedHost | AttachedHost | Connected |
+ | Bgp | Bgp | Connected | IS-IS |
+ | Connected | Connected | IS-IS | OSPF Internal |
+ | Dynamic | DHCP | OSPF Internal | OSPF External |
+ | IS-IS | Dynamic | OSPF External | OSPF Nssa-External |
+ | OSPF Internal | IS-IS | OSPF Nssa-External | OSPFv3 Internal |
+ | OSPF External | OSPFv3 Internal | OSPFv3 Internal | OSPFv3 External |
+ | OSPF Nssa-External | OSPFv3 External | OSPFv3 External | OSPFv3 Nssa-External |
+ | OSPFv3 Internal | OSPFv3 Nssa-External | OSPFv3 Nssa-External | Static |
+ | OSPFv3 External | Static | Static | |
+ | OSPFv3 Nssa-External | User | | |
+ | RIP | | | |
+ | Static | | | |
+ | User | | | |
+ """
+ for routes_data in self.redistributed_routes:
+ if all([self.afi_safi == "v4u", routes_data.proto == "DHCP"]):
+ msg = f"Redistributed protocol 'DHCP' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'"
+ raise ValueError(msg)
+
+ if self.afi_safi == "v6u" and routes_data.proto in ["OSPF Internal", "OSPF External", "OSPF Nssa-External", "RIP"]:
+ msg = f"Redistributed protocol '{routes_data.proto}' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'"
+ raise ValueError(msg)
+
+ if self.afi_safi == "v4m" and routes_data.proto not in IPV4_MULTICAST_SUPPORTED_PROTO:
+ msg = f"Redistributed protocol '{routes_data.proto}' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'"
+ raise ValueError(msg)
+
+ if self.afi_safi == "v6m" and routes_data.proto not in IPV6_MULTICAST_SUPPORTED_PROTO:
+ msg = f"Redistributed protocol '{routes_data.proto}' is not supported for address-family '{AFI_SAFI_MAPPINGS[self.afi_safi]}'"
+ raise ValueError(msg)
+
+ return self
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the AddressFamilyConfig for reporting.
+
+ Examples
+ --------
+ - AFI-SAFI: IPv4 Unicast
+ """
+ return f"AFI-SAFI: {AFI_SAFI_MAPPINGS[self.afi_safi]}"
diff --git a/anta/input_models/routing/generic.py b/anta/input_models/routing/generic.py
index 41c78a1..72609fc 100644
--- a/anta/input_models/routing/generic.py
+++ b/anta/input_models/routing/generic.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for generic routing tests."""
from __future__ import annotations
-from ipaddress import IPv4Network
+from ipaddress import IPv4Address, IPv4Network
from pydantic import BaseModel, ConfigDict
@@ -17,12 +17,18 @@ class IPv4Routes(BaseModel):
model_config = ConfigDict(extra="forbid")
prefix: IPv4Network
- """The IPV4 network to validate the route type."""
+ """IPv4 prefix in CIDR notation."""
vrf: str = "default"
"""VRF context. Defaults to `default` VRF."""
- route_type: IPv4RouteType
- """List of IPV4 Route type to validate the valid rout type."""
+ route_type: IPv4RouteType | None = None
+ """Expected route type. Required field in the `VerifyIPv4RouteType` test."""
+ nexthops: list[IPv4Address] | None = None
+ """A list of the next-hop IP addresses for the route. Required field in the `VerifyIPv4RouteNextHops` test."""
+ strict: bool = False
+ """If True, requires exact matching of provided nexthop(s).
+
+ Can be enabled in `VerifyIPv4RouteNextHops` test."""
def __str__(self) -> str:
- """Return a human-readable string representation of the IPv4RouteType for reporting."""
+ """Return a human-readable string representation of the IPv4Routes for reporting."""
return f"Prefix: {self.prefix} VRF: {self.vrf}"
diff --git a/anta/input_models/routing/isis.py b/anta/input_models/routing/isis.py
new file mode 100644
index 0000000..c0e2649
--- /dev/null
+++ b/anta/input_models/routing/isis.py
@@ -0,0 +1,202 @@
+# Copyright (c) 2023-2025 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 containing input models for routing IS-IS tests."""
+
+from __future__ import annotations
+
+from ipaddress import IPv4Address, IPv4Network
+from typing import Any, Literal
+from warnings import warn
+
+from pydantic import BaseModel, ConfigDict
+
+from anta.custom_types import Interface
+
+
+class ISISInstance(BaseModel):
+ """Model for an IS-IS instance."""
+
+ model_config = ConfigDict(extra="forbid")
+ name: str
+ """The name of the IS-IS instance."""
+ vrf: str = "default"
+ """VRF context of the IS-IS instance."""
+ dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS"
+ """Configured SR data-plane for the IS-IS instance."""
+ segments: list[Segment] | None = None
+ """List of IS-IS SR segments associated with the instance. Required field in the `VerifyISISSegmentRoutingAdjacencySegments` test."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the ISISInstance for reporting."""
+ return f"Instance: {self.name} VRF: {self.vrf}"
+
+
+class Segment(BaseModel):
+ """Model for an IS-IS segment."""
+
+ model_config = ConfigDict(extra="forbid")
+ interface: Interface
+ """Local interface name."""
+ level: Literal[1, 2] = 2
+ """IS-IS level of the segment."""
+ sid_origin: Literal["dynamic", "configured"] = "dynamic"
+ """Origin of the segment ID."""
+ address: IPv4Address
+ """Adjacency IPv4 address of the segment."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the Segment for reporting."""
+ return f"Local Intf: {self.interface} Adj IP Address: {self.address}"
+
+
+class ISISInterface(BaseModel):
+ """Model for an IS-IS enabled interface."""
+
+ model_config = ConfigDict(extra="forbid")
+ name: Interface
+ """Interface name."""
+ vrf: str = "default"
+ """VRF context of the interface."""
+ level: Literal[1, 2] = 2
+ """IS-IS level of the interface."""
+ count: int | None = None
+ """Expected number of IS-IS neighbors on this interface. Required field in the `VerifyISISNeighborCount` test."""
+ mode: Literal["point-to-point", "broadcast", "passive"] | None = None
+ """IS-IS network type of the interface. Required field in the `VerifyISISInterfaceMode` test."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the ISISInterface for reporting."""
+ return f"Interface: {self.name} VRF: {self.vrf} Level: {self.level}"
+
+
+class InterfaceCount(ISISInterface): # pragma: no cover
+ """Alias for the ISISInterface model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the ISISInterface model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the InterfaceCount class, emitting a deprecation warning."""
+ warn(
+ message="InterfaceCount model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInterface model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
+
+
+class InterfaceState(ISISInterface): # pragma: no cover
+ """Alias for the ISISInterface model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the ISISInterface model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the InterfaceState class, emitting a deprecation warning."""
+ warn(
+ message="InterfaceState model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInterface model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
+
+
+class IsisInstance(ISISInstance): # pragma: no cover
+ """Alias for the ISISInstance model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the ISISInstance model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the IsisInstance class, emitting a deprecation warning."""
+ warn(
+ message="IsisInstance model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInstance model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
+
+
+class Tunnel(BaseModel):
+ """Model for a IS-IS SR tunnel."""
+
+ model_config = ConfigDict(extra="forbid")
+ endpoint: IPv4Network
+ """Endpoint of the tunnel."""
+ vias: list[TunnelPath] | None = None
+ """Optional list of paths to reach the endpoint."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the Tunnel for reporting."""
+ return f"Endpoint: {self.endpoint}"
+
+
+class TunnelPath(BaseModel):
+ """Model for a IS-IS tunnel path."""
+
+ model_config = ConfigDict(extra="forbid")
+ nexthop: IPv4Address | None = None
+ """Nexthop of the tunnel."""
+ type: Literal["ip", "tunnel"] | None = None
+ """Type of the tunnel."""
+ interface: Interface | None = None
+ """Interface of the tunnel."""
+ tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None
+ """Computation method of the tunnel."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the TunnelPath for reporting."""
+ base_string = ""
+ if self.nexthop:
+ base_string += f" Next-hop: {self.nexthop}"
+ if self.type:
+ base_string += f" Type: {self.type}"
+ if self.interface:
+ base_string += f" Interface: {self.interface}"
+ if self.tunnel_id:
+ base_string += f" Tunnel ID: {self.tunnel_id}"
+
+ return base_string.lstrip()
+
+
+class Entry(Tunnel): # pragma: no cover
+ """Alias for the Tunnel model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the Tunnel model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the Entry class, emitting a deprecation warning."""
+ warn(
+ message="Entry model is deprecated and will be removed in ANTA v2.0.0. Use the Tunnel model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
+
+
+class Vias(TunnelPath): # pragma: no cover
+ """Alias for the TunnelPath model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the TunnelPath model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the Vias class, emitting a deprecation warning."""
+ warn(
+ message="Vias model is deprecated and will be removed in ANTA v2.0.0. Use the TunnelPath model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
diff --git a/anta/input_models/security.py b/anta/input_models/security.py
index 373d897..79bdc17 100644
--- a/anta/input_models/security.py
+++ b/anta/input_models/security.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for security tests."""
@@ -6,10 +6,20 @@
from __future__ import annotations
from ipaddress import IPv4Address
-from typing import Any
+from typing import TYPE_CHECKING, Any, ClassVar, get_args
from warnings import warn
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize
+
+if TYPE_CHECKING:
+ import sys
+
+ if sys.version_info >= (3, 11):
+ from typing import Self
+ else:
+ from typing_extensions import Self
class IPSecPeer(BaseModel):
@@ -43,6 +53,107 @@ class IPSecConn(BaseModel):
"""The IPv4 address of the destination in the security connection."""
+class APISSLCertificate(BaseModel):
+ """Model for an API SSL certificate."""
+
+ model_config = ConfigDict(extra="forbid")
+ certificate_name: str
+ """The name of the certificate to be verified."""
+ expiry_threshold: int
+ """The expiry threshold of the certificate in days."""
+ common_name: str
+ """The Common Name of the certificate."""
+ encryption_algorithm: EncryptionAlgorithm
+ """The encryption algorithm used by the certificate."""
+ key_size: RsaKeySize | EcdsaKeySize
+ """The key size (in bits) of the encryption algorithm."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the APISSLCertificate for reporting.
+
+ Examples
+ --------
+ - Certificate: SIGNING_CA.crt
+ """
+ return f"Certificate: {self.certificate_name}"
+
+ @model_validator(mode="after")
+ def validate_inputs(self) -> Self:
+ """Validate the key size provided to the APISSLCertificates class.
+
+ If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
+
+ If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
+ """
+ if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
+ msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
+ raise ValueError(msg)
+
+ if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
+ msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
+ raise ValueError(msg)
+
+ return self
+
+
+class ACLEntry(BaseModel):
+ """Model for an Access Control List (ACL) entry."""
+
+ model_config = ConfigDict(extra="forbid")
+ sequence: int = Field(ge=1, le=4294967295)
+ """Sequence number of the ACL entry, used to define the order of processing. Must be between 1 and 4294967295."""
+ action: str
+ """Action of the ACL entry. Example: `deny ip any any`."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the ACLEntry for reporting.
+
+ Examples
+ --------
+ - Sequence: 10
+ """
+ return f"Sequence: {self.sequence}"
+
+
+class ACL(BaseModel):
+ """Model for an Access Control List (ACL)."""
+
+ model_config = ConfigDict(extra="forbid")
+ name: str
+ """Name of the ACL."""
+ entries: list[ACLEntry]
+ """List of the ACL entries."""
+ IPv4ACLEntry: ClassVar[type[ACLEntry]] = ACLEntry
+ """To maintain backward compatibility."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the ACL for reporting.
+
+ Examples
+ --------
+ - ACL name: Test
+ """
+ return f"ACL name: {self.name}"
+
+
+class IPv4ACL(ACL): # pragma: no cover
+ """Alias for the ACL model to maintain backward compatibility.
+
+ When initialized, it will emit a deprecation warning and call the ACL model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the IPv4ACL class, emitting a deprecation warning."""
+ warn(
+ message="IPv4ACL model is deprecated and will be removed in ANTA v2.0.0. Use the ACL model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
+
+
class IPSecPeers(IPSecPeer): # pragma: no cover
"""Alias for the IPSecPeers model to maintain backward compatibility.
@@ -52,7 +163,7 @@ class IPSecPeers(IPSecPeer): # pragma: no cover
"""
def __init__(self, **data: Any) -> None: # noqa: ANN401
- """Initialize the IPSecPeer class, emitting a deprecation warning."""
+ """Initialize the IPSecPeers class, emitting a deprecation warning."""
warn(
message="IPSecPeers model is deprecated and will be removed in ANTA v2.0.0. Use the IPSecPeer model instead.",
category=DeprecationWarning,
diff --git a/anta/input_models/services.py b/anta/input_models/services.py
index 596a3e3..0c602c8 100644
--- a/anta/input_models/services.py
+++ b/anta/input_models/services.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for services tests."""
@@ -6,9 +6,13 @@
from __future__ import annotations
from ipaddress import IPv4Address, IPv6Address
+from typing import Any, Literal
+from warnings import warn
from pydantic import BaseModel, ConfigDict, Field
+from anta.custom_types import ErrDisableReasons
+
class DnsServer(BaseModel):
"""Model for a DNS server configuration."""
@@ -28,4 +32,43 @@ class DnsServer(BaseModel):
--------
Server 10.0.0.1 (VRF: default, Priority: 1)
"""
- return f"Server {self.server_address} (VRF: {self.vrf}, Priority: {self.priority})"
+ return f"Server {self.server_address} VRF: {self.vrf} Priority: {self.priority}"
+
+
+class ErrdisableRecovery(BaseModel):
+ """Model for the error disable recovery functionality."""
+
+ model_config = ConfigDict(extra="forbid")
+ reason: ErrDisableReasons
+ """Name of the error disable reason."""
+ status: Literal["Enabled", "Disabled"] = "Enabled"
+ """Operational status of the reason. Defaults to 'Enabled'."""
+ interval: int = Field(ge=30, le=86400)
+ """Timer interval of the reason in seconds."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the ErrdisableRecovery for reporting.
+
+ Examples
+ --------
+ Reason: acl Status: Enabled Interval: 300
+ """
+ return f"Reason: {self.reason} Status: {self.status} Interval: {self.interval}"
+
+
+class ErrDisableReason(ErrdisableRecovery): # pragma: no cover
+ """Alias for the ErrdisableRecovery model to maintain backward compatibility.
+
+ When initialised, it will emit a deprecation warning and call the ErrdisableRecovery model.
+
+ TODO: Remove this class in ANTA v2.0.0.
+ """
+
+ def __init__(self, **data: Any) -> None: # noqa: ANN401
+ """Initialize the ErrdisableRecovery class, emitting a depreciation warning."""
+ warn(
+ message="ErrDisableReason model is deprecated and will be removed in ANTA v2.0.0. Use the ErrdisableRecovery model instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(**data)
diff --git a/anta/input_models/snmp.py b/anta/input_models/snmp.py
new file mode 100644
index 0000000..d5f1408
--- /dev/null
+++ b/anta/input_models/snmp.py
@@ -0,0 +1,127 @@
+# Copyright (c) 2023-2025 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 containing input models for SNMP tests."""
+
+from __future__ import annotations
+
+from ipaddress import IPv4Address
+from typing import TYPE_CHECKING, Literal
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from anta.custom_types import Hostname, Interface, Port, SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion, SnmpVersionV3AuthType
+
+if TYPE_CHECKING:
+ import sys
+
+ if sys.version_info >= (3, 11):
+ from typing import Self
+ else:
+ from typing_extensions import Self
+
+
+class SnmpHost(BaseModel):
+ """Model for a SNMP host."""
+
+ model_config = ConfigDict(extra="forbid")
+ hostname: IPv4Address | Hostname
+ """IPv4 address or Hostname of the SNMP notification host."""
+ vrf: str = "default"
+ """Optional VRF for SNMP Hosts. If not provided, it defaults to `default`."""
+ notification_type: Literal["trap", "inform"] = "trap"
+ """Type of SNMP notification (trap or inform), it defaults to trap."""
+ version: SnmpVersion | None = None
+ """SNMP protocol version. Required field in the `VerifySnmpNotificationHost` test."""
+ udp_port: Port | int = 162
+ """UDP port for SNMP. If not provided then defaults to 162."""
+ community_string: str | None = None
+ """Optional SNMP community string for authentication,required for SNMP version is v1 or v2c. Can be provided in the `VerifySnmpNotificationHost` test."""
+ user: str | None = None
+ """Optional SNMP user for authentication, required for SNMP version v3. Can be provided in the `VerifySnmpNotificationHost` test."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the SnmpHost for reporting.
+
+ Examples
+ --------
+ - Host: 192.168.1.100 VRF: default
+ """
+ return f"Host: {self.hostname} VRF: {self.vrf}"
+
+
+class SnmpUser(BaseModel):
+ """Model for a SNMP User."""
+
+ model_config = ConfigDict(extra="forbid")
+ username: str
+ """SNMP user name."""
+ group_name: str
+ """SNMP group for the user."""
+ version: SnmpVersion
+ """SNMP protocol version."""
+ auth_type: SnmpHashingAlgorithm | None = None
+ """User authentication algorithm. Can be provided in the `VerifySnmpUser` test."""
+ priv_type: SnmpEncryptionAlgorithm | None = None
+ """User privacy algorithm. Can be provided in the `VerifySnmpUser` test."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the SnmpUser for reporting.
+
+ Examples
+ --------
+ - User: Test Group: Test_Group Version: v2c
+ """
+ return f"User: {self.username} Group: {self.group_name} Version: {self.version}"
+
+
+class SnmpSourceInterface(BaseModel):
+ """Model for a SNMP source-interface."""
+
+ interface: Interface
+ """Interface to use as the source IP address of SNMP messages."""
+ vrf: str = "default"
+ """VRF of the source interface."""
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the SnmpSourceInterface for reporting.
+
+ Examples
+ --------
+ - Source Interface: Ethernet1 VRF: default
+ """
+ return f"Source Interface: {self.interface} VRF: {self.vrf}"
+
+
+class SnmpGroup(BaseModel):
+ """Model for an SNMP group."""
+
+ group_name: str
+ """SNMP group name."""
+ version: SnmpVersion
+ """SNMP protocol version."""
+ read_view: str | None = None
+ """Optional field, View to restrict read access."""
+ write_view: str | None = None
+ """Optional field, View to restrict write access."""
+ notify_view: str | None = None
+ """Optional field, View to restrict notifications."""
+ authentication: SnmpVersionV3AuthType | None = None
+ """SNMPv3 authentication settings. Required when version is v3. Can be provided in the `VerifySnmpGroup` test."""
+
+ @model_validator(mode="after")
+ def validate_inputs(self) -> Self:
+ """Validate the inputs provided to the SnmpGroup class."""
+ if self.version == "v3" and self.authentication is None:
+ msg = f"{self!s}: `authentication` field is missing in the input"
+ raise ValueError(msg)
+ return self
+
+ def __str__(self) -> str:
+ """Return a human-readable string representation of the SnmpGroup for reporting.
+
+ Examples
+ --------
+ - Group: Test_Group Version: v2c
+ """
+ return f"Group: {self.group_name} Version: {self.version}"
diff --git a/anta/input_models/stun.py b/anta/input_models/stun.py
index d1af405..1d91567 100644
--- a/anta/input_models/stun.py
+++ b/anta/input_models/stun.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for services tests."""
diff --git a/anta/input_models/system.py b/anta/input_models/system.py
index 7600d28..3e098c4 100644
--- a/anta/input_models/system.py
+++ b/anta/input_models/system.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 containing input models for system tests."""
@@ -7,9 +7,9 @@ from __future__ import annotations
from ipaddress import IPv4Address
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict
-from anta.custom_types import Hostname
+from anta.custom_types import Hostname, NTPStratumLevel
class NTPServer(BaseModel):
@@ -22,10 +22,20 @@ class NTPServer(BaseModel):
For example, 'ntp.example.com' in the configuration might resolve to 'ntp3.example.com' in the device output."""
preferred: bool = False
"""Optional preferred for NTP server. If not provided, it defaults to `False`."""
- stratum: int = Field(ge=0, le=16)
+ stratum: NTPStratumLevel
"""NTP stratum level (0 to 15) where 0 is the reference clock and 16 indicates unsynchronized.
Values should be between 0 and 15 for valid synchronization and 16 represents an out-of-sync state."""
def __str__(self) -> str:
"""Representation of the NTPServer model."""
- return f"{self.server_address} (Preferred: {self.preferred}, Stratum: {self.stratum})"
+ return f"NTP Server: {self.server_address} Preferred: {self.preferred} Stratum: {self.stratum}"
+
+
+class NTPPool(BaseModel):
+ """Model for a NTP server pool."""
+
+ model_config = ConfigDict(extra="forbid")
+ server_addresses: list[Hostname | IPv4Address]
+ """The list of NTP server addresses as an IPv4 addresses or hostnames."""
+ preferred_stratum_range: list[NTPStratumLevel]
+ """Preferred NTP stratum range for the NTP server pool. If the expected stratum range is 1 to 3 then preferred_stratum_range should be `[1,3]`."""
diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py
index 3046d7a..a74638e 100644
--- a/anta/inventory/__init__.py
+++ b/anta/inventory/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Inventory module for ANTA."""
@@ -8,15 +8,16 @@ from __future__ import annotations
import asyncio
import logging
from ipaddress import ip_address, ip_network
+from json import load as json_load
from pathlib import Path
-from typing import Any, ClassVar
+from typing import Any, ClassVar, Literal
from pydantic import ValidationError
from yaml import YAMLError, safe_load
from anta.device import AntaDevice, AsyncEOSDevice
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
-from anta.inventory.models import AntaInventoryInput
+from anta.inventory.models import AntaInventoryHost, AntaInventoryInput
from anta.logger import anta_log_exception
logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ class AntaInventory(dict[str, AntaDevice]):
"""Inventory abstraction for ANTA framework."""
# Root key of inventory part of the inventory file
- INVENTORY_ROOT_KEY = "anta_inventory"
+ INVENTORY_ROOT_KEY: str = "anta_inventory"
# Supported Output format
INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"]
@@ -178,6 +179,7 @@ class AntaInventory(dict[str, AntaDevice]):
password: str,
enable_password: str | None = None,
timeout: float | None = None,
+ file_format: Literal["yaml", "json"] = "yaml",
*,
enable: bool = False,
insecure: bool = False,
@@ -199,6 +201,8 @@ class AntaInventory(dict[str, AntaDevice]):
Enable password to use if required.
timeout
Timeout value in seconds for outgoing API calls.
+ file_format
+ Whether the inventory file is in JSON or YAML.
enable
Whether or not the commands need to be run in enable mode towards the devices.
insecure
@@ -214,6 +218,10 @@ class AntaInventory(dict[str, AntaDevice]):
Inventory file is not following AntaInventory Schema.
"""
+ if file_format not in ["yaml", "json"]:
+ message = f"'{file_format}' is not a valid format for an AntaInventory file. Only 'yaml' and 'json' are supported."
+ raise ValueError(message)
+
inventory = AntaInventory()
kwargs: dict[str, Any] = {
"username": username,
@@ -224,20 +232,12 @@ class AntaInventory(dict[str, AntaDevice]):
"insecure": insecure,
"disable_cache": disable_cache,
}
- if username is None:
- message = "'username' is required to create an AntaInventory"
- logger.error(message)
- raise ValueError(message)
- if password is None:
- message = "'password' is required to create an AntaInventory"
- logger.error(message)
- raise ValueError(message)
try:
filename = Path(filename)
with filename.open(encoding="UTF-8") as file:
- data = safe_load(file)
- except (TypeError, YAMLError, OSError) as e:
+ data = safe_load(file) if file_format == "yaml" else json_load(file)
+ except (TypeError, YAMLError, OSError, ValueError) as e:
message = f"Unable to parse ANTA Device Inventory file '{filename}'"
anta_log_exception(e, message, logger)
raise
@@ -342,3 +342,20 @@ class AntaInventory(dict[str, AntaDevice]):
if isinstance(r, Exception):
message = "Error when refreshing inventory"
anta_log_exception(r, message, logger)
+
+ def dump(self) -> AntaInventoryInput:
+ """Dump the AntaInventory to an AntaInventoryInput.
+
+ Each hosts is dumped individually.
+ """
+ hosts = [
+ AntaInventoryHost(
+ name=device.name,
+ host=device.host if hasattr(device, "host") else device.name,
+ port=device.port if hasattr(device, "port") else None,
+ tags=device.tags,
+ disable_cache=device.cache is None,
+ )
+ for device in self.devices
+ ]
+ return AntaInventoryInput(hosts=hosts)
diff --git a/anta/inventory/exceptions.py b/anta/inventory/exceptions.py
index 90a672f..f7adaa7 100644
--- a/anta/inventory/exceptions.py
+++ b/anta/inventory/exceptions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Manage Exception in Inventory module."""
diff --git a/anta/inventory/models.py b/anta/inventory/models.py
index 2eea701..493bad7 100644
--- a/anta/inventory/models.py
+++ b/anta/inventory/models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Models related to inventory management."""
@@ -9,14 +9,26 @@ import logging
import math
import yaml
-from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork
+from pydantic import BaseModel, ConfigDict, FieldSerializationInfo, IPvAnyAddress, IPvAnyNetwork, field_serializer
from anta.custom_types import Hostname, Port
logger = logging.getLogger(__name__)
-class AntaInventoryHost(BaseModel):
+class AntaInventoryBaseModel(BaseModel):
+ """Pydantic BaseModel for AntaInventory objects."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ # Using check_fields as we plan to use this in the child classes
+ @field_serializer("tags", when_used="json", check_fields=False)
+ def serialize_tags(self, tags: set[str], _info: FieldSerializationInfo) -> list[str]:
+ """Make sure the tags are always dumped in the same order."""
+ return sorted(tags)
+
+
+class AntaInventoryHost(AntaInventoryBaseModel):
"""Host entry of AntaInventoryInput.
Attributes
@@ -34,8 +46,6 @@ class AntaInventoryHost(BaseModel):
"""
- model_config = ConfigDict(extra="forbid")
-
name: str | None = None
host: Hostname | IPvAnyAddress
port: Port | None = None
@@ -43,7 +53,7 @@ class AntaInventoryHost(BaseModel):
disable_cache: bool = False
-class AntaInventoryNetwork(BaseModel):
+class AntaInventoryNetwork(AntaInventoryBaseModel):
"""Network entry of AntaInventoryInput.
Attributes
@@ -57,14 +67,12 @@ class AntaInventoryNetwork(BaseModel):
"""
- model_config = ConfigDict(extra="forbid")
-
network: IPvAnyNetwork
tags: set[str] | None = None
disable_cache: bool = False
-class AntaInventoryRange(BaseModel):
+class AntaInventoryRange(AntaInventoryBaseModel):
"""IP Range entry of AntaInventoryInput.
Attributes
@@ -80,8 +88,6 @@ class AntaInventoryRange(BaseModel):
"""
- model_config = ConfigDict(extra="forbid")
-
start: IPvAnyAddress
end: IPvAnyAddress
tags: set[str] | None = None
@@ -109,4 +115,13 @@ class AntaInventoryInput(BaseModel):
# This could be improved.
# https://github.com/pydantic/pydantic/issues/1043
# Explore if this worth using this: https://github.com/NowanIlfideme/pydantic-yaml
- return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), indent=2, width=math.inf)
+ return yaml.safe_dump(yaml.safe_load(self.model_dump_json(serialize_as_any=True, exclude_unset=True)), width=math.inf)
+
+ def to_json(self) -> str:
+ """Return a JSON representation string of this model.
+
+ Returns
+ -------
+ The JSON representation string of this model.
+ """
+ return self.model_dump_json(serialize_as_any=True, exclude_unset=True, indent=2)
diff --git a/anta/logger.py b/anta/logger.py
index 54733fb..e6d0428 100644
--- a/anta/logger.py
+++ b/anta/logger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Configure logging for ANTA."""
@@ -9,15 +9,13 @@ import logging
import traceback
from datetime import timedelta
from enum import Enum
-from typing import TYPE_CHECKING, Literal
+from pathlib import Path
+from typing import Literal
from rich.logging import RichHandler
from anta import __DEBUG__
-if TYPE_CHECKING:
- from pathlib import Path
-
logger = logging.getLogger(__name__)
@@ -69,27 +67,59 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None:
# httpx as well
logging.getLogger("httpx").setLevel(logging.WARNING)
- # Add RichHandler for stdout
- rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
- # Show Python module in stdout at DEBUG level
- fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s"
- formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
- rich_handler.setFormatter(formatter)
- root.addHandler(rich_handler)
- # Add FileHandler if file is provided
- if file:
+ # Add RichHandler for stdout if not already present
+ _maybe_add_rich_handler(loglevel, root)
+
+ # Add FileHandler if file is provided and same File Handler is not already present
+ if file and not _get_file_handler(root, file):
file_handler = logging.FileHandler(file)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
# If level is DEBUG and file is provided, do not send DEBUG level to stdout
- if loglevel == logging.DEBUG:
+ if loglevel == logging.DEBUG and (rich_handler := _get_rich_handler(root)) is not None:
rich_handler.setLevel(logging.INFO)
if __DEBUG__:
logger.debug("ANTA Debug Mode enabled")
+def _get_file_handler(logger_instance: logging.Logger, file: Path) -> logging.FileHandler | None:
+ """Return the FileHandler if present."""
+ return (
+ next(
+ (
+ handler
+ for handler in logger_instance.handlers
+ if isinstance(handler, logging.FileHandler) and str(Path(handler.baseFilename).resolve()) == str(file.resolve())
+ ),
+ None,
+ )
+ if logger_instance.hasHandlers()
+ else None
+ )
+
+
+def _get_rich_handler(logger_instance: logging.Logger) -> logging.Handler | None:
+ """Return the ANTA Rich Handler."""
+ return next((handler for handler in logger_instance.handlers if handler.get_name() == "ANTA_RICH_HANDLER"), None) if logger_instance.hasHandlers() else None
+
+
+def _maybe_add_rich_handler(loglevel: int, logger_instance: logging.Logger) -> None:
+ """Add RichHandler for stdout if not already present."""
+ if _get_rich_handler(logger_instance) is not None:
+ # Nothing to do.
+ return
+
+ anta_rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False)
+ anta_rich_handler.set_name("ANTA_RICH_HANDLER")
+ # Show Python module in stdout at DEBUG level
+ fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if loglevel == logging.DEBUG else "%(message)s"
+ formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]")
+ anta_rich_handler.setFormatter(formatter)
+ logger_instance.addHandler(anta_rich_handler)
+
+
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)
diff --git a/anta/models.py b/anta/models.py
index c69f78e..172f032 100644
--- a/anta/models.py
+++ b/anta/models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Models to define a TestStructure."""
@@ -15,9 +15,8 @@ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
-from anta import GITHUB_SUGGESTION
-from anta.constants import KNOWN_EOS_ERRORS
-from anta.custom_types import REGEXP_EOS_BLACKLIST_CMDS, Revision
+from anta.constants import EOS_BLACKLIST_CMDS, KNOWN_EOS_ERRORS, UNSUPPORTED_PLATFORM_ERRORS
+from anta.custom_types import Revision
from anta.logger import anta_log_exception, exc_to_str
from anta.result_manager.models import AntaTestStatus, TestResult
@@ -81,7 +80,7 @@ class AntaTemplate:
# 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}
+ fields: dict[str, Any] = dict.fromkeys(field_names, (Any, ...))
self.params_schema = create_model(
"AntaParams",
__base__=AntaParamsBaseModel,
@@ -258,7 +257,8 @@ class AntaCommand(BaseModel):
msg = f"Command '{self.command}' has not been collected and has not returned an error. Call AntaDevice.collect()."
raise RuntimeError(msg)
- return all("not supported on this hardware platform" not in e for e in self.errors)
+
+ return not any(any(error in e for error in UNSUPPORTED_PLATFORM_ERRORS) for e in self.errors)
@property
def returned_known_eos_error(self) -> bool:
@@ -432,7 +432,7 @@ class AntaTest(ABC):
inputs: dict[str, Any] | AntaTest.Input | None = None,
eos_data: list[dict[Any, Any] | str] | None = None,
) -> None:
- """AntaTest Constructor.
+ """Initialize an AntaTest instance.
Parameters
----------
@@ -575,12 +575,12 @@ class AntaTest(ABC):
"""Check if CLI commands contain a blocked keyword."""
state = False
for command in self.instance_commands:
- for pattern in REGEXP_EOS_BLACKLIST_CMDS:
+ for pattern in EOS_BLACKLIST_CMDS:
if re.match(pattern, command.command):
self.logger.error(
"Command <%s> is blocked for security reason matching %s",
command.command,
- REGEXP_EOS_BLACKLIST_CMDS,
+ EOS_BLACKLIST_CMDS,
)
self.result.is_error(f"<{command.command}> is blocked for security reason")
state = True
@@ -683,8 +683,6 @@ class AntaTest(ABC):
cmds = self.failed_commands
unsupported_commands = [f"'{c.command}' is not supported on {self.device.hw_model}" for c in cmds if not c.supported]
if unsupported_commands:
- msg = f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}"
- self.logger.warning(msg)
self.result.is_skipped("\n".join(unsupported_commands))
return
returned_known_eos_error = [f"'{c.command}' failed on {self.device.name}: {', '.join(c.errors)}" for c in cmds if c.returned_known_eos_error]
diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py
index 9e5fa1b..5156ea7 100644
--- a/anta/reporter/__init__.py
+++ b/anta/reporter/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Report management for ANTA."""
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
class ReportTable:
"""TableReport Generate a Table based on TestResult."""
- @dataclass()
+ @dataclass
class Headers: # pylint: disable=too-many-instance-attributes
"""Headers for the table report."""
@@ -168,7 +168,7 @@ class ReportTable:
self.Headers.list_of_error_nodes,
]
table = self._build_headers(headers=headers, table=table)
- for test, stats in sorted(manager.test_stats.items()):
+ for test, stats in manager.test_stats.items():
if tests is None or test in tests:
table.add_row(
test,
@@ -214,7 +214,7 @@ class ReportTable:
self.Headers.list_of_error_tests,
]
table = self._build_headers(headers=headers, table=table)
- for device, stats in sorted(manager.device_stats.items()):
+ for device, stats in manager.device_stats.items():
if devices is None or device in devices:
table.add_row(
device,
diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py
index 3f55923..2a0a4de 100644
--- a/anta/reporter/csv_reporter.py
+++ b/anta/reporter/csv_reporter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""CSV Report management for ANTA."""
@@ -8,6 +8,7 @@ from __future__ import annotations
import csv
import logging
+import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -111,6 +112,7 @@ class ReportCsv:
csvwriter = csv.writer(
csvfile,
delimiter=",",
+ lineterminator=os.linesep,
)
csvwriter.writerow(headers)
for entry in results.results:
diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py
index 94c4a86..2d2d882 100644
--- a/anta/reporter/md_reporter.py
+++ b/anta/reporter/md_reporter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Markdown report generator for ANTA test results."""
@@ -177,8 +177,8 @@ class MDReportBase(ABC):
if text is None:
return ""
- # Replace newlines with spaces to keep content on one line
- text = text.replace("\n", " ")
+ # Replace newlines with
to preserve line breaks in HTML
+ text = text.replace("\n", "
")
# Replace backticks with single quotes
return text.replace("`", "'")
@@ -237,7 +237,7 @@ class SummaryTotalsDeviceUnderTest(MDReportBase):
def generate_rows(self) -> Generator[str, None, None]:
"""Generate the rows of the summary totals device under test table."""
for device, stat in self.results.device_stats.items():
- total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
+ total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + stat.tests_unset_count
categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped))))
categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed))))
yield (
@@ -261,10 +261,11 @@ class SummaryTotalsPerCategory(MDReportBase):
def generate_rows(self) -> Generator[str, None, None]:
"""Generate the rows of the summary totals per category table."""
- for category, stat in self.results.sorted_category_stats.items():
- total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
+ for category, stat in self.results.category_stats.items():
+ converted_category = convert_categories([category])[0]
+ total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + stat.tests_unset_count
yield (
- f"| {category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} "
+ f"| {converted_category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} "
f"| {stat.tests_error_count} |\n"
)
@@ -284,9 +285,9 @@ class TestResults(MDReportBase):
def generate_rows(self) -> Generator[str, None, None]:
"""Generate the rows of the all test results table."""
- for result in self.results.get_results(sort_by=["name", "test"]):
- messages = self.safe_markdown(", ".join(result.messages))
- categories = ", ".join(convert_categories(result.categories))
+ for result in self.results.results:
+ messages = self.safe_markdown(result.messages[0]) if len(result.messages) == 1 else self.safe_markdown("
".join(result.messages))
+ categories = ", ".join(sorted(convert_categories(result.categories)))
yield (
f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} "
f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n"
diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py
index b5b0f39..39ed364 100644
--- a/anta/result_manager/__init__.py
+++ b/anta/result_manager/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Result Manager module for ANTA."""
@@ -12,6 +12,8 @@ from functools import cached_property
from itertools import chain
from typing import Any
+from typing_extensions import deprecated
+
from anta.result_manager.models import AntaTestStatus, TestResult
from .models import CategoryStats, DeviceStats, TestStats
@@ -21,56 +23,40 @@ logger = logging.getLogger(__name__)
# pylint: disable=too-many-instance-attributes
class ResultManager:
- """Helper to manage Test Results and generate reports.
+ """Manager of ANTA Results.
- Examples
- --------
- Create Inventory:
+ The status of the class is initialized to "unset"
- inventory_anta = AntaInventory.parse(
- filename='examples/inventory.yml',
- username='ansible',
- password='ansible',
- )
+ Then when adding a test with a status that is NOT 'error' the following
+ table shows the updated status:
- Create Result Manager:
+ | Current Status | Added test Status | Updated Status |
+ | -------------- | ------------------------------- | -------------- |
+ | unset | Any | Any |
+ | skipped | unset, skipped | skipped |
+ | skipped | success | success |
+ | skipped | failure | failure |
+ | success | unset, skipped, success | success |
+ | success | failure | failure |
+ | failure | unset, skipped success, failure | failure |
- manager = ResultManager()
+ If the status of the added test is error, the status is untouched and the
+ `error_status` attribute is set to True.
- Run tests for all connected devices:
-
- for device in inventory_anta.get_inventory().devices:
- manager.add(
- VerifyNTP(device=device).test()
- )
- manager.add(
- VerifyEOSVersion(device=device).test(version='4.28.3M')
- )
-
- Print result in native format:
-
- manager.results
- [
- TestResult(
- name="pf1",
- test="VerifyZeroTouch",
- categories=["configuration"],
- description="Verifies ZeroTouch is disabled",
- result="success",
- messages=[],
- custom_field=None,
- ),
- TestResult(
- name="pf1",
- test='VerifyNTP',
- categories=["software"],
- categories=['system'],
- description='Verifies if NTP is synchronised.',
- result='failure',
- messages=["The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'"],
- custom_field=None,
- ),
- ]
+ Attributes
+ ----------
+ results
+ dump
+ status
+ Status rerpesenting all the results.
+ error_status
+ Will be `True` if a test returned an error.
+ results_by_status
+ dump
+ json
+ device_stats
+ category_stats
+ test_stats
"""
_result_entries: list[TestResult]
@@ -83,26 +69,7 @@ class ResultManager:
_stats_in_sync: bool
def __init__(self) -> None:
- """Class constructor.
-
- The status of the class is initialized to "unset"
-
- Then when adding a test with a status that is NOT 'error' the following
- table shows the updated status:
-
- | Current Status | Added test Status | Updated Status |
- | -------------- | ------------------------------- | -------------- |
- | unset | Any | Any |
- | skipped | unset, skipped | skipped |
- | skipped | success | success |
- | skipped | failure | failure |
- | success | unset, skipped, success | success |
- | success | failure | failure |
- | failure | unset, skipped success, failure | failure |
-
- If the status of the added test is error, the status is untouched and the
- error_status is set to True.
- """
+ """Initialize a ResultManager instance."""
self.reset()
def reset(self) -> None:
@@ -143,28 +110,28 @@ class ResultManager:
return json.dumps(self.dump, indent=4)
@property
- def device_stats(self) -> defaultdict[str, DeviceStats]:
+ def device_stats(self) -> dict[str, DeviceStats]:
"""Get the device statistics."""
self._ensure_stats_in_sync()
- return self._device_stats
+ return dict(sorted(self._device_stats.items()))
@property
- def category_stats(self) -> defaultdict[str, CategoryStats]:
+ def category_stats(self) -> dict[str, CategoryStats]:
"""Get the category statistics."""
self._ensure_stats_in_sync()
- return self._category_stats
+ return dict(sorted(self._category_stats.items()))
@property
- def test_stats(self) -> defaultdict[str, TestStats]:
+ def test_stats(self) -> dict[str, TestStats]:
"""Get the test statistics."""
self._ensure_stats_in_sync()
- return self._test_stats
+ return dict(sorted(self._test_stats.items()))
@property
+ @deprecated("This property is deprecated, use `category_stats` instead. This will be removed in ANTA v2.0.0.", category=DeprecationWarning)
def sorted_category_stats(self) -> dict[str, CategoryStats]:
"""A property that returns the category_stats dictionary sorted by key name."""
- self._ensure_stats_in_sync()
- return dict(sorted(self.category_stats.items()))
+ return self.category_stats
@cached_property
def results_by_status(self) -> dict[AntaTestStatus, list[TestResult]]:
@@ -316,6 +283,21 @@ class ResultManager:
"""Return the current status including error_status if ignore_error is False."""
return "error" if self.error_status and not ignore_error else self.status
+ def sort(self, sort_by: list[str]) -> ResultManager:
+ """Sort the ResultManager results based on TestResult fields.
+
+ Parameters
+ ----------
+ sort_by
+ List of TestResult fields to sort the results.
+ """
+ accepted_fields = TestResult.model_fields.keys()
+ if not set(sort_by).issubset(set(accepted_fields)):
+ msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}"
+ raise ValueError(msg)
+ self._result_entries.sort(key=lambda result: [getattr(result, field) for field in sort_by])
+ return self
+
def filter(self, hide: set[AntaTestStatus]) -> ResultManager:
"""Get a filtered ResultManager based on test status.
@@ -334,6 +316,7 @@ class ResultManager:
manager.results = self.get_results(possible_statuses - hide)
return manager
+ @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning)
def filter_by_tests(self, tests: set[str]) -> ResultManager:
"""Get a filtered ResultManager that only contains specific tests.
@@ -351,6 +334,7 @@ class ResultManager:
manager.results = [result for result in self._result_entries if result.test in tests]
return manager
+ @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning)
def filter_by_devices(self, devices: set[str]) -> ResultManager:
"""Get a filtered ResultManager that only contains specific devices.
@@ -368,6 +352,7 @@ class ResultManager:
manager.results = [result for result in self._result_entries if result.name in devices]
return manager
+ @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning)
def get_tests(self) -> set[str]:
"""Get the set of all the test names.
@@ -378,6 +363,7 @@ class ResultManager:
"""
return {str(result.test) for result in self._result_entries}
+ @deprecated("This method is deprecated. This will be removed in ANTA v2.0.0.", category=DeprecationWarning)
def get_devices(self) -> set[str]:
"""Get the set of all the device names.
diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py
index 3297581..a18ff57 100644
--- a/anta/result_manager/models.py
+++ b/anta/result_manager/models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Models related to anta.result_manager module."""
diff --git a/anta/runner.py b/anta/runner.py
index 4c6da92..84e27a1 100644
--- a/anta/runner.py
+++ b/anta/runner.py
@@ -1,7 +1,7 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 function."""
+"""ANTA runner module."""
from __future__ import annotations
@@ -115,7 +115,7 @@ async def setup_inventory(inventory: AntaInventory, tags: set[str] | None, devic
# 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 ""}'
+ 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
@@ -170,8 +170,7 @@ def prepare_tests(
if total_test_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."
+ 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
diff --git a/anta/tests/__init__.py b/anta/tests/__init__.py
index ec0b1ec..15362fc 100644
--- a/anta/tests/__init__.py
+++ b/anta/tests/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to all ANTA tests."""
diff --git a/anta/tests/aaa.py b/anta/tests/aaa.py
index 019bf1a..a135fca 100644
--- a/anta/tests/aaa.py
+++ b/anta/tests/aaa.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various AAA tests."""
@@ -51,12 +51,12 @@ class VerifyTacacsSourceIntf(AntaTest):
"""Main test function for VerifyTacacsSourceIntf."""
command_output = self.instance_commands[0].json_output
try:
- if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf:
+ if (src_interface := command_output["srcIntf"][self.inputs.vrf]) == self.inputs.intf:
self.result.is_success()
else:
- self.result.is_failure(f"Wrong source-interface configured in VRF {self.inputs.vrf}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Source interface mismatch - Expected: {self.inputs.intf} Actual: {src_interface}")
except KeyError:
- self.result.is_failure(f"Source-interface {self.inputs.intf} is not configured in VRF {self.inputs.vrf}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} Source Interface: {self.inputs.intf} - Not configured")
class VerifyTacacsServers(AntaTest):
@@ -108,7 +108,7 @@ class VerifyTacacsServers(AntaTest):
if not not_configured:
self.result.is_success()
else:
- self.result.is_failure(f"TACACS servers {not_configured} are not configured in VRF {self.inputs.vrf}")
+ self.result.is_failure(f"TACACS servers {', '.join(not_configured)} are not configured in VRF {self.inputs.vrf}")
class VerifyTacacsServerGroups(AntaTest):
@@ -151,7 +151,7 @@ class VerifyTacacsServerGroups(AntaTest):
if not not_configured:
self.result.is_success()
else:
- self.result.is_failure(f"TACACS server group(s) {not_configured} are not configured")
+ self.result.is_failure(f"TACACS server group(s) {', '.join(not_configured)} are not configured")
class VerifyAuthenMethods(AntaTest):
@@ -204,14 +204,14 @@ class VerifyAuthenMethods(AntaTest):
self.result.is_failure("AAA authentication methods are not configured for login console")
return
if v["login"]["methods"] != self.inputs.methods:
- self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console")
+ self.result.is_failure(f"AAA authentication methods {', '.join(self.inputs.methods)} are not matching for login console")
return
not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods)
if not not_matching:
self.result.is_success()
else:
- self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for {not_matching}")
+ self.result.is_failure(f"AAA authentication methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}")
class VerifyAuthzMethods(AntaTest):
@@ -263,7 +263,7 @@ class VerifyAuthzMethods(AntaTest):
if not not_matching:
self.result.is_success()
else:
- self.result.is_failure(f"AAA authorization methods {self.inputs.methods} are not matching for {not_matching}")
+ self.result.is_failure(f"AAA authorization methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}")
class VerifyAcctDefaultMethods(AntaTest):
@@ -319,12 +319,12 @@ class VerifyAcctDefaultMethods(AntaTest):
if methods["defaultMethods"] != self.inputs.methods:
not_matching.append(acct_type)
if not_configured:
- self.result.is_failure(f"AAA default accounting is not configured for {not_configured}")
+ self.result.is_failure(f"AAA default accounting is not configured for {', '.join(not_configured)}")
return
if not not_matching:
self.result.is_success()
else:
- self.result.is_failure(f"AAA accounting default methods {self.inputs.methods} are not matching for {not_matching}")
+ self.result.is_failure(f"AAA accounting default methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}")
class VerifyAcctConsoleMethods(AntaTest):
@@ -380,9 +380,9 @@ class VerifyAcctConsoleMethods(AntaTest):
if methods["consoleMethods"] != self.inputs.methods:
not_matching.append(acct_type)
if not_configured:
- self.result.is_failure(f"AAA console accounting is not configured for {not_configured}")
+ self.result.is_failure(f"AAA console accounting is not configured for {', '.join(not_configured)}")
return
if not not_matching:
self.result.is_success()
else:
- self.result.is_failure(f"AAA accounting console methods {self.inputs.methods} are not matching for {not_matching}")
+ self.result.is_failure(f"AAA accounting console methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}")
diff --git a/anta/tests/avt.py b/anta/tests/avt.py
index b0f1a46..2173510 100644
--- a/anta/tests/avt.py
+++ b/anta/tests/avt.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -47,7 +47,7 @@ class VerifyAVTPathHealth(AntaTest):
# Check if AVT is configured
if not command_output:
- self.result.is_failure("Adaptive virtual topology paths are not configured.")
+ self.result.is_failure("Adaptive virtual topology paths are not configured")
return
# Iterate over each VRF
@@ -61,11 +61,11 @@ class VerifyAVTPathHealth(AntaTest):
# 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.")
+ self.result.is_failure(f"VRF: {vrf} Profile: {profile} AVT path: {path} - Invalid and not active")
elif not valid:
- self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is invalid.")
+ self.result.is_failure(f"VRF: {vrf} Profile: {profile} AVT path: {path} - Invalid")
elif not active:
- self.result.is_failure(f"AVT path {path} for profile {profile} in VRF {vrf} is not active.")
+ self.result.is_failure(f"VRF: {vrf} Profile: {profile} AVT path: {path} - Not active")
class VerifyAVTSpecificPath(AntaTest):
@@ -143,7 +143,7 @@ class VerifyAVTSpecificPath(AntaTest):
valid = get_value(path_data, "flags.valid")
active = get_value(path_data, "flags.active")
if not all([valid, active]):
- self.result.is_failure(f"{avt_path} - Incorrect path {path} - Valid: {valid}, Active: {active}")
+ self.result.is_failure(f"{avt_path} - Incorrect path {path} - Valid: {valid} Active: {active}")
# If no matching path found, mark the test as failed
if not path_found:
@@ -192,4 +192,4 @@ class VerifyAVTRole(AntaTest):
# 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.")
+ self.result.is_failure(f"AVT role mismatch - Expected: {self.inputs.role} Actual: {command_output.get('role')}")
diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py
index ba27f94..f677ae1 100644
--- a/anta/tests/bfd.py
+++ b/anta/tests/bfd.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 BFD tests."""
@@ -8,9 +8,9 @@
from __future__ import annotations
from datetime import datetime, timezone
-from typing import TYPE_CHECKING, ClassVar
+from typing import TYPE_CHECKING, ClassVar, TypeVar
-from pydantic import Field
+from pydantic import Field, field_validator
from anta.input_models.bfd import BFDPeer
from anta.models import AntaCommand, AntaTest
@@ -19,6 +19,9 @@ from anta.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
+# Using a TypeVar for the BFDPeer model since mypy thinks it's a ClassVar and not a valid type when used in field validators
+T = TypeVar("T", bound=BFDPeer)
+
class VerifyBFDSpecificPeers(AntaTest):
"""Verifies the state of IPv4 BFD peer sessions.
@@ -99,15 +102,18 @@ class VerifyBFDPeersIntervals(AntaTest):
1. Confirms that the specified VRF is configured.
2. Verifies that the peer exists in the BFD configuration.
3. Confirms that BFD peer is correctly configured with the `Transmit interval, Receive interval and Multiplier`.
+ 4. Verifies that BFD peer is correctly configured with the `Detection time`, if provided.
Expected Results
----------------
* Success: If all of the following conditions are met:
- All specified peers are found in the BFD configuration within the specified VRF.
- All BFD peers are correctly configured with the `Transmit interval, Receive interval and Multiplier`.
+ - If provided, the `Detection time` is correctly configured.
* Failure: If any of the following occur:
- A specified peer is not found in the BFD configuration within the specified VRF.
- Any BFD peer not correctly configured with the `Transmit interval, Receive interval and Multiplier`.
+ - Any BFD peer is not correctly configured with `Detection time`, if provided.
Examples
--------
@@ -125,6 +131,7 @@ class VerifyBFDPeersIntervals(AntaTest):
tx_interval: 1200
rx_interval: 1200
multiplier: 3
+ detection_time: 3600
```
"""
@@ -139,6 +146,23 @@ class VerifyBFDPeersIntervals(AntaTest):
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility"""
+ @field_validator("bfd_peers")
+ @classmethod
+ def validate_bfd_peers(cls, bfd_peers: list[T]) -> list[T]:
+ """Validate that 'tx_interval', 'rx_interval' and 'multiplier' fields are provided in each BFD peer."""
+ for peer in bfd_peers:
+ missing_fileds = []
+ if peer.tx_interval is None:
+ missing_fileds.append("tx_interval")
+ if peer.rx_interval is None:
+ missing_fileds.append("rx_interval")
+ if peer.multiplier is None:
+ missing_fileds.append("multiplier")
+ if missing_fileds:
+ msg = f"{peer} {', '.join(missing_fileds)} field(s) are missing in the input"
+ raise ValueError(msg)
+ return bfd_peers
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBFDPeersIntervals."""
@@ -151,6 +175,7 @@ class VerifyBFDPeersIntervals(AntaTest):
tx_interval = bfd_peer.tx_interval
rx_interval = bfd_peer.rx_interval
multiplier = bfd_peer.multiplier
+ detect_time = bfd_peer.detection_time
# Check if BFD peer configured
bfd_output = get_value(
@@ -166,6 +191,7 @@ class VerifyBFDPeersIntervals(AntaTest):
bfd_details = bfd_output.get("peerStatsDetail", {})
op_tx_interval = bfd_details.get("operTxInterval") // 1000
op_rx_interval = bfd_details.get("operRxInterval") // 1000
+ op_detection_time = bfd_details.get("detectTime") // 1000
detect_multiplier = bfd_details.get("detectMult")
if op_tx_interval != tx_interval:
@@ -177,6 +203,9 @@ class VerifyBFDPeersIntervals(AntaTest):
if detect_multiplier != multiplier:
self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {multiplier} Actual: {detect_multiplier}")
+ if detect_time and op_detection_time != detect_time:
+ self.result.is_failure(f"{bfd_peer} - Incorrect Detection Time - Expected: {detect_time} Actual: {op_detection_time}")
+
class VerifyBFDPeersHealth(AntaTest):
"""Verifies the health of IPv4 BFD peers across all VRFs.
@@ -231,7 +260,7 @@ class VerifyBFDPeersHealth(AntaTest):
# Check if any IPv4 BFD peer is configured
ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values())
if not ipv4_neighbors_exist:
- self.result.is_failure("No IPv4 BFD peers are configured for any VRF.")
+ self.result.is_failure("No IPv4 BFD peers are configured for any VRF")
return
# Iterate over IPv4 BFD peers
@@ -299,6 +328,16 @@ class VerifyBFDPeersRegProtocols(AntaTest):
BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer
"""To maintain backward compatibility"""
+ @field_validator("bfd_peers")
+ @classmethod
+ def validate_bfd_peers(cls, bfd_peers: list[T]) -> list[T]:
+ """Validate that 'protocols' field is provided in each BFD peer."""
+ for peer in bfd_peers:
+ if peer.protocols is None:
+ msg = f"{peer} 'protocols' field missing in the input"
+ raise ValueError(msg)
+ return bfd_peers
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBFDPeersRegProtocols."""
@@ -323,5 +362,5 @@ class VerifyBFDPeersRegProtocols(AntaTest):
# Check registered protocols
difference = sorted(set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps")))
if difference:
- failures = " ".join(f"`{item}`" for item in difference)
+ failures = ", ".join(f"`{item}`" for item in difference)
self.result.is_failure(f"{bfd_peer} - {failures} routing protocol(s) not configured")
diff --git a/anta/tests/configuration.py b/anta/tests/configuration.py
index cff7ec6..a1c57a1 100644
--- a/anta/tests/configuration.py
+++ b/anta/tests/configuration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the device configuration tests."""
@@ -125,4 +125,4 @@ class VerifyRunningConfigLines(AntaTest):
if not failure_msgs:
self.result.is_success()
else:
- self.result.is_failure("Following patterns were not found: " + ",".join(failure_msgs))
+ self.result.is_failure("Following patterns were not found: " + ", ".join(failure_msgs))
diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py
index afcfa11..a5ba5ff 100644
--- a/anta/tests/connectivity.py
+++ b/anta/tests/connectivity.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 various connectivity tests."""
@@ -7,11 +7,16 @@
# mypy: disable-error-code=attr-defined
from __future__ import annotations
-from typing import ClassVar
+from typing import ClassVar, TypeVar
+
+from pydantic import field_validator
from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor
from anta.models import AntaCommand, AntaTemplate, AntaTest
+# Using a TypeVar for the Host model since mypy thinks it's a ClassVar and not a valid type when used in field validators
+T = TypeVar("T", bound=Host)
+
class VerifyReachability(AntaTest):
"""Test network reachability to one or many destination IP(s).
@@ -32,11 +37,18 @@ class VerifyReachability(AntaTest):
vrf: MGMT
df_bit: True
size: 100
+ reachable: true
- source: Management0
destination: 8.8.8.8
vrf: MGMT
df_bit: True
size: 100
+ - source: fd12:3456:789a:1::1
+ destination: fd12:3456:789a:1::2
+ vrf: default
+ df_bit: True
+ size: 100
+ reachable: false
```
"""
@@ -54,6 +66,16 @@ class VerifyReachability(AntaTest):
Host: ClassVar[type[Host]] = Host
"""To maintain backward compatibility."""
+ @field_validator("hosts")
+ @classmethod
+ def validate_hosts(cls, hosts: list[T]) -> list[T]:
+ """Validate the 'destination' and 'source' IP address family in each host."""
+ for host in hosts:
+ if not isinstance(host.source, str) and host.destination.version != host.source.version:
+ msg = f"{host} IP address family for destination does not match source"
+ raise ValueError(msg)
+ return hosts
+
def render(self, template: AntaTemplate) -> list[AntaCommand]:
"""Render the template for each host in the input list."""
return [
@@ -69,9 +91,14 @@ class VerifyReachability(AntaTest):
self.result.is_success()
for command, host in zip(self.instance_commands, self.inputs.hosts):
- if f"{host.repeat} received" not in command.json_output["messages"][0]:
+ # Verifies the network is reachable
+ if host.reachable and f"{host.repeat} received" not in command.json_output["messages"][0]:
self.result.is_failure(f"{host} - Unreachable")
+ # Verifies the network is unreachable.
+ if not host.reachable and f"{host.repeat} received" in command.json_output["messages"][0]:
+ self.result.is_failure(f"{host} - Destination is expected to be unreachable but found reachable")
+
class VerifyLLDPNeighbors(AntaTest):
"""Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors.
diff --git a/anta/tests/cvx.py b/anta/tests/cvx.py
index 6160082..25d8245 100644
--- a/anta/tests/cvx.py
+++ b/anta/tests/cvx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the CVX tests."""
@@ -49,7 +49,7 @@ class VerifyMcsClientMounts(AntaTest):
continue
mcs_mount_state_detected = True
if (state := mount_state["state"]) != "mountStateMountComplete":
- self.result.is_failure(f"MCS Client mount states are not valid: {state}")
+ self.result.is_failure(f"MCS Client mount states are not valid - Expected: mountStateMountComplete Actual: {state}")
if not mcs_mount_state_detected:
self.result.is_failure("MCS Client mount states are not present")
@@ -88,7 +88,12 @@ class VerifyManagementCVX(AntaTest):
command_output = self.instance_commands[0].json_output
self.result.is_success()
if (cluster_state := get_value(command_output, "clusterStatus.enabled")) != self.inputs.enabled:
- self.result.is_failure(f"Management CVX status is not valid: {cluster_state}")
+ if cluster_state is None:
+ self.result.is_failure("Management CVX status - Not configured")
+ return
+ cluster_state = "enabled" if cluster_state else "disabled"
+ self.inputs.enabled = "enabled" if self.inputs.enabled else "disabled"
+ self.result.is_failure(f"Management CVX status is not valid: Expected: {self.inputs.enabled} Actual: {cluster_state}")
class VerifyMcsServerMounts(AntaTest):
@@ -126,13 +131,15 @@ class VerifyMcsServerMounts(AntaTest):
mount_states = mount["mountStates"][0]
if (num_path_states := len(mount_states["pathStates"])) != (expected_num := len(self.mcs_path_types)):
- self.result.is_failure(f"Incorrect number of mount path states for {hostname} - Expected: {expected_num}, Actual: {num_path_states}")
+ self.result.is_failure(f"Host: {hostname} - Incorrect number of mount path states - Expected: {expected_num} Actual: {num_path_states}")
for path in mount_states["pathStates"]:
if (path_type := path.get("type")) not in self.mcs_path_types:
- self.result.is_failure(f"Unexpected MCS path type for {hostname}: '{path_type}'.")
+ self.result.is_failure(f"Host: {hostname} - Unexpected MCS path type - Expected: {', '.join(self.mcs_path_types)} Actual: {path_type}")
if (path_state := path.get("state")) != "mountStateMountComplete":
- self.result.is_failure(f"MCS server mount state for path '{path_type}' is not valid is for {hostname}: '{path_state}'.")
+ self.result.is_failure(
+ f"Host: {hostname} Path Type: {path_type} - MCS server mount state is not valid - Expected: mountStateMountComplete Actual:{path_state}"
+ )
@AntaTest.anta_test
def test(self) -> None:
@@ -152,18 +159,18 @@ class VerifyMcsServerMounts(AntaTest):
mcs_mounts = [mount for mount in mounts if mount["service"] == "Mcs"]
if not mounts:
- self.result.is_failure(f"No mount status for {hostname}")
+ self.result.is_failure(f"Host: {hostname} - No mount status found")
continue
if not mcs_mounts:
- self.result.is_failure(f"MCS mount state not detected for {hostname}")
+ self.result.is_failure(f"Host: {hostname} - MCS mount state not detected")
else:
for mount in mcs_mounts:
self.validate_mount_states(mount, hostname)
active_count += 1
if active_count != self.inputs.connections_count:
- self.result.is_failure(f"Incorrect CVX successful connections count. Expected: {self.inputs.connections_count}, Actual : {active_count}")
+ self.result.is_failure(f"Incorrect CVX successful connections count - Expected: {self.inputs.connections_count} Actual: {active_count}")
class VerifyActiveCVXConnections(AntaTest):
@@ -200,13 +207,13 @@ class VerifyActiveCVXConnections(AntaTest):
self.result.is_success()
if not (connections := command_output.get("connections")):
- self.result.is_failure("CVX connections are not available.")
+ self.result.is_failure("CVX connections are not available")
return
active_count = len([connection for connection in connections if connection.get("oobConnectionActive")])
if self.inputs.connections_count != active_count:
- self.result.is_failure(f"CVX active connections count. Expected: {self.inputs.connections_count}, Actual : {active_count}")
+ self.result.is_failure(f"Incorrect CVX active connections count - Expected: {self.inputs.connections_count} Actual: {active_count}")
class VerifyCVXClusterStatus(AntaTest):
@@ -261,7 +268,7 @@ class VerifyCVXClusterStatus(AntaTest):
# Check cluster role
if (cluster_role := cluster_status.get("role")) != self.inputs.role:
- self.result.is_failure(f"CVX Role is not valid: {cluster_role}")
+ self.result.is_failure(f"CVX Role is not valid: Expected: {self.inputs.role} Actual: {cluster_role}")
return
# Validate peer status
@@ -269,15 +276,15 @@ class VerifyCVXClusterStatus(AntaTest):
# Check peer count
if (num_of_peers := len(peer_cluster)) != (expected_num_of_peers := len(self.inputs.peer_status)):
- self.result.is_failure(f"Unexpected number of peers {num_of_peers} vs {expected_num_of_peers}")
+ self.result.is_failure(f"Unexpected number of peers - Expected: {expected_num_of_peers} Actual: {num_of_peers}")
# Check each peer
for peer in self.inputs.peer_status:
# Retrieve the peer status from the peer cluster
if (eos_peer_status := get_value(peer_cluster, peer.peer_name, separator="..")) is None:
- self.result.is_failure(f"{peer.peer_name} is not present")
+ self.result.is_failure(f"{peer.peer_name} - Not present")
continue
# Validate the registration state of the peer
if (peer_reg_state := eos_peer_status.get("registrationState")) != peer.registration_state:
- self.result.is_failure(f"{peer.peer_name} registration state is not complete: {peer_reg_state}")
+ self.result.is_failure(f"{peer.peer_name} - Invalid registration state - Expected: {peer.registration_state} Actual: {peer_reg_state}")
diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py
index 41e81a8..cc7fab9 100644
--- a/anta/tests/field_notices.py
+++ b/anta/tests/field_notices.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 field notices tests."""
@@ -96,7 +96,7 @@ class VerifyFieldNotice44Resolution(AntaTest):
for variant in variants:
model = model.replace(variant, "")
if model not in devices:
- self.result.is_skipped("device is not impacted by FN044")
+ self.result.is_skipped("Device is not impacted by FN044")
return
for component in command_output["details"]["components"]:
@@ -117,7 +117,7 @@ class VerifyFieldNotice44Resolution(AntaTest):
)
)
if incorrect_aboot_version:
- self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})")
+ self.result.is_failure(f"Device is running incorrect version of aboot {aboot_version}")
class VerifyFieldNotice72Resolution(AntaTest):
diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py
index 9b9acc6..a115949 100644
--- a/anta/tests/flow_tracking.py
+++ b/anta/tests/flow_tracking.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the flow tracking tests."""
@@ -9,37 +9,13 @@ from __future__ import annotations
from typing import ClassVar
-from pydantic import BaseModel
-
from anta.decorators import skip_on_platforms
+from anta.input_models.flow_tracking import FlowTracker
from anta.models import AntaCommand, AntaTemplate, AntaTest
-from anta.tools import get_failed_logs
+from anta.tools import get_value
-def validate_record_export(record_export: dict[str, str], tracker_info: dict[str, str]) -> str:
- """Validate the record export configuration against the tracker info.
-
- Parameters
- ----------
- record_export
- The expected record export configuration.
- tracker_info
- The actual tracker info from the command output.
-
- Returns
- -------
- str
- A failure message if the record export configuration does not match, otherwise blank string.
- """
- failed_log = ""
- actual_export = {"inactive timeout": tracker_info.get("inactiveTimeout"), "interval": tracker_info.get("activeInterval")}
- expected_export = {"inactive timeout": record_export.get("on_inactive_timeout"), "interval": record_export.get("on_interval")}
- if actual_export != expected_export:
- failed_log = get_failed_logs(expected_export, actual_export)
- return failed_log
-
-
-def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> str:
+def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> list[str]:
"""Validate the exporter configurations against the tracker info.
Parameters
@@ -51,36 +27,52 @@ def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str,
Returns
-------
- str
- Failure message if any exporter configuration does not match.
+ list
+ List of failure messages for any exporter configuration that does not match.
"""
- failed_log = ""
+ failure_messages = []
for exporter in exporters:
- exporter_name = exporter["name"]
+ exporter_name = exporter.name
actual_exporter_info = tracker_info["exporters"].get(exporter_name)
if not actual_exporter_info:
- failed_log += f"\nExporter `{exporter_name}` is not configured."
+ failure_messages.append(f"{exporter} - Not configured")
continue
+ local_interface = actual_exporter_info["localIntf"]
+ template_interval = actual_exporter_info["templateInterval"]
- expected_exporter_data = {"local interface": exporter["local_interface"], "template interval": exporter["template_interval"]}
- actual_exporter_data = {"local interface": actual_exporter_info["localIntf"], "template interval": actual_exporter_info["templateInterval"]}
+ if local_interface != exporter.local_interface:
+ failure_messages.append(f"{exporter} - Incorrect local interface - Expected: {exporter.local_interface} Actual: {local_interface}")
- if expected_exporter_data != actual_exporter_data:
- failed_msg = get_failed_logs(expected_exporter_data, actual_exporter_data)
- failed_log += f"\nExporter `{exporter_name}`: {failed_msg}"
- return failed_log
+ if template_interval != exporter.template_interval:
+ failure_messages.append(f"{exporter} - Incorrect template interval - Expected: {exporter.template_interval} Actual: {template_interval}")
+ return failure_messages
class VerifyHardwareFlowTrackerStatus(AntaTest):
- """Verifies if hardware flow tracking is running and an input tracker is active.
+ """Verifies the hardware flow tracking state.
- This test optionally verifies the tracker interval/timeout and exporter configuration.
+ This test performs the following checks:
+
+ 1. Confirms that hardware flow tracking is running.
+ 2. For each specified flow tracker:
+ - Confirms that the tracker is active.
+ - Optionally, checks the tracker interval/timeout configuration.
+ - Optionally, verifies the tracker exporter configuration
Expected Results
----------------
- * Success: The test will pass if hardware flow tracking is running and an input tracker is active.
- * Failure: The test will fail if hardware flow tracking is not running, an input tracker is not active,
- or the tracker interval/timeout and exporter configuration does not match the expected values.
+ * Success: The test will pass if all of the following conditions are met:
+ - Hardware flow tracking is running.
+ - For each specified flow tracker:
+ - The flow tracker is active.
+ - The tracker interval/timeout matches the expected values, if provided.
+ - The exporter configuration matches the expected values, if provided.
+ * Failure: The test will fail if any of the following conditions are met:
+ - Hardware flow tracking is not running.
+ - For any specified flow tracker:
+ - The flow tracker is not active.
+ - The tracker interval/timeout does not match the expected values, if provided.
+ - The exporter configuration does not match the expected values, if provided.
Examples
--------
@@ -99,11 +91,8 @@ class VerifyHardwareFlowTrackerStatus(AntaTest):
```
"""
- description = (
- "Verifies if hardware flow tracking is running and an input tracker is active. Optionally verifies the tracker interval/timeout and exporter configuration."
- )
categories: ClassVar[list[str]] = ["flow tracking"]
- commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show flow tracking hardware tracker {name}", revision=1)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show flow tracking hardware", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyHardwareFlowTrackerStatus test."""
@@ -111,82 +100,42 @@ class VerifyHardwareFlowTrackerStatus(AntaTest):
trackers: list[FlowTracker]
"""List of flow trackers to verify."""
- class FlowTracker(BaseModel):
- """Detail of a flow tracker."""
-
- name: str
- """Name of the flow tracker."""
-
- record_export: RecordExport | None = None
- """Record export configuration for the flow tracker."""
-
- exporters: list[Exporter] | None = None
- """List of exporters for the flow tracker."""
-
- class RecordExport(BaseModel):
- """Record export configuration."""
-
- on_inactive_timeout: int
- """Timeout in milliseconds for exporting records when inactive."""
-
- on_interval: int
- """Interval in milliseconds for exporting records."""
-
- class Exporter(BaseModel):
- """Detail of an exporter."""
-
- name: str
- """Name of the exporter."""
-
- local_interface: str
- """Local interface used by the exporter."""
-
- template_interval: int
- """Template interval in milliseconds for the exporter."""
-
- def render(self, template: AntaTemplate) -> list[AntaCommand]:
- """Render the template for each hardware tracker."""
- return [template.render(name=tracker.name) for tracker in self.inputs.trackers]
-
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyHardwareFlowTrackerStatus."""
self.result.is_success()
- for command, tracker_input in zip(self.instance_commands, self.inputs.trackers):
- hardware_tracker_name = command.params.name
- record_export = tracker_input.record_export.model_dump() if tracker_input.record_export else None
- exporters = [exporter.model_dump() for exporter in tracker_input.exporters] if tracker_input.exporters else None
- command_output = command.json_output
- # Check if hardware flow tracking is configured
- if not command_output.get("running"):
- self.result.is_failure("Hardware flow tracking is not running.")
- return
+ command_output = self.instance_commands[0].json_output
+ # Check if hardware flow tracking is configured
+ if not command_output.get("running"):
+ self.result.is_failure("Hardware flow tracking is not running.")
+ return
+ for tracker in self.inputs.trackers:
# Check if the input hardware tracker is configured
- tracker_info = command_output["trackers"].get(hardware_tracker_name)
- if not tracker_info:
- self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not configured.")
+ if not (tracker_info := get_value(command_output["trackers"], f"{tracker.name}")):
+ self.result.is_failure(f"{tracker} - Not found")
continue
# Check if the input hardware tracker is active
if not tracker_info.get("active"):
- self.result.is_failure(f"Hardware flow tracker `{hardware_tracker_name}` is not active.")
+ self.result.is_failure(f"{tracker} - Disabled")
continue
# Check the input hardware tracker timeouts
- failure_msg = ""
- if record_export:
- record_export_failure = validate_record_export(record_export, tracker_info)
- if record_export_failure:
- failure_msg += record_export_failure
+ if tracker.record_export:
+ inactive_interval = tracker.record_export.on_inactive_timeout
+ on_interval = tracker.record_export.on_interval
+ act_inactive = tracker_info.get("inactiveTimeout")
+ act_interval = tracker_info.get("activeInterval")
+ if not all([inactive_interval == act_inactive, on_interval == act_interval]):
+ self.result.is_failure(
+ f"{tracker} {tracker.record_export} - Incorrect timers - Inactive Timeout: {act_inactive} OnActive Interval: {act_interval}"
+ )
- # Check the input hardware tracker exporters' configuration
- if exporters:
- exporters_failure = validate_exporters(exporters, tracker_info)
- if exporters_failure:
- failure_msg += exporters_failure
-
- if failure_msg:
- self.result.is_failure(f"{hardware_tracker_name}: {failure_msg}\n")
+ # Check the input hardware tracker exporters configuration
+ if tracker.exporters:
+ failure_messages = validate_exporters(tracker.exporters, tracker_info)
+ for message in failure_messages:
+ self.result.is_failure(f"{tracker} {message}")
diff --git a/anta/tests/greent.py b/anta/tests/greent.py
index 67bb25b..345f01b 100644
--- a/anta/tests/greent.py
+++ b/anta/tests/greent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 GreenT (Postcard Telemetry) tests."""
diff --git a/anta/tests/hardware.py b/anta/tests/hardware.py
index 1c562b0..7edd41b 100644
--- a/anta/tests/hardware.py
+++ b/anta/tests/hardware.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the hardware or environment tests."""
@@ -49,14 +49,14 @@ class VerifyTransceiversManufacturers(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTransceiversManufacturers."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- wrong_manufacturers = {
- interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers
- }
- if not wrong_manufacturers:
- self.result.is_success()
- else:
- self.result.is_failure(f"Some transceivers are from unapproved manufacturers: {wrong_manufacturers}")
+ for interface, value in command_output["xcvrSlots"].items():
+ if value["mfgName"] not in self.inputs.manufacturers:
+ self.result.is_failure(
+ f"Interface: {interface} - Transceiver is from unapproved manufacturers - Expected: {', '.join(self.inputs.manufacturers)}"
+ f" Actual: {value['mfgName']}"
+ )
class VerifyTemperature(AntaTest):
@@ -82,12 +82,11 @@ class VerifyTemperature(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTemperature."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
temperature_status = command_output.get("systemStatus", "")
- if temperature_status == "temperatureOk":
- self.result.is_success()
- else:
- self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'")
+ if temperature_status != "temperatureOk":
+ self.result.is_failure(f"Device temperature exceeds acceptable limits - Expected: temperatureOk Actual: {temperature_status}")
class VerifyTransceiversTemperature(AntaTest):
@@ -113,20 +112,14 @@ class VerifyTransceiversTemperature(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyTransceiversTemperature."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
sensors = command_output.get("tempSensors", "")
- wrong_sensors = {
- sensor["name"]: {
- "hwStatus": sensor["hwStatus"],
- "alertCount": sensor["alertCount"],
- }
- for sensor in sensors
- if sensor["hwStatus"] != "ok" or sensor["alertCount"] != 0
- }
- if not wrong_sensors:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following sensors are operating outside the acceptable temperature range or have raised alerts: {wrong_sensors}")
+ for sensor in sensors:
+ if sensor["hwStatus"] != "ok":
+ self.result.is_failure(f"Sensor: {sensor['name']} - Invalid hardware state - Expected: ok Actual: {sensor['hwStatus']}")
+ if sensor["alertCount"] != 0:
+ self.result.is_failure(f"Sensor: {sensor['name']} - Incorrect alert counter - Expected: 0 Actual: {sensor['alertCount']}")
class VerifyEnvironmentSystemCooling(AntaTest):
@@ -156,7 +149,7 @@ class VerifyEnvironmentSystemCooling(AntaTest):
sys_status = command_output.get("systemStatus", "")
self.result.is_success()
if sys_status != "coolingOk":
- self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'")
+ self.result.is_failure(f"Device system cooling status invalid - Expected: coolingOk Actual: {sys_status}")
class VerifyEnvironmentCooling(AntaTest):
@@ -177,8 +170,6 @@ class VerifyEnvironmentCooling(AntaTest):
```
"""
- name = "VerifyEnvironmentCooling"
- description = "Verifies the status of power supply fans and all fan trays."
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)]
@@ -198,12 +189,16 @@ class VerifyEnvironmentCooling(AntaTest):
for power_supply in command_output.get("powerSupplySlots", []):
for fan in power_supply.get("fans", []):
if (state := fan["status"]) not in self.inputs.states:
- self.result.is_failure(f"Fan {fan['label']} on PowerSupply {power_supply['label']} is: '{state}'")
+ self.result.is_failure(
+ f"Power Slot: {power_supply['label']} Fan: {fan['label']} - Invalid state - Expected: {', '.join(self.inputs.states)} Actual: {state}"
+ )
# Then go through fan trays
for fan_tray in command_output.get("fanTraySlots", []):
for fan in fan_tray.get("fans", []):
if (state := fan["status"]) not in self.inputs.states:
- self.result.is_failure(f"Fan {fan['label']} on Fan Tray {fan_tray['label']} is: '{state}'")
+ self.result.is_failure(
+ f"Fan Tray: {fan_tray['label']} Fan: {fan['label']} - Invalid state - Expected: {', '.join(self.inputs.states)} Actual: {state}"
+ )
class VerifyEnvironmentPower(AntaTest):
@@ -237,19 +232,16 @@ class VerifyEnvironmentPower(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyEnvironmentPower."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
power_supplies = command_output.get("powerSupplies", "{}")
- wrong_power_supplies = {
- powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states
- }
- if not wrong_power_supplies:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following power supplies status are not in the accepted states list: {wrong_power_supplies}")
+ for power_supply, value in dict(power_supplies).items():
+ if (state := value["state"]) not in self.inputs.states:
+ self.result.is_failure(f"Power Slot: {power_supply} - Invalid power supplies state - Expected: {', '.join(self.inputs.states)} Actual: {state}")
class VerifyAdverseDrops(AntaTest):
- """Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips).
+ """Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches.
Expected Results
----------------
@@ -264,7 +256,6 @@ class VerifyAdverseDrops(AntaTest):
```
"""
- description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches."
categories: ClassVar[list[str]] = ["hardware"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)]
@@ -272,9 +263,8 @@ class VerifyAdverseDrops(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyAdverseDrops."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
total_adverse_drop = command_output.get("totalAdverseDrops", "")
- if total_adverse_drop == 0:
- self.result.is_success()
- else:
- self.result.is_failure(f"Device totalAdverseDrops counter is: '{total_adverse_drop}'")
+ if total_adverse_drop != 0:
+ self.result.is_failure(f"Incorrect total adverse drops counter - Expected: 0 Actual: {total_adverse_drop}")
diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py
index bc1acbb..e291bd6 100644
--- a/anta/tests/interfaces.py
+++ b/anta/tests/interfaces.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the device interfaces tests."""
@@ -8,21 +8,22 @@
from __future__ import annotations
import re
-from ipaddress import IPv4Interface
-from typing import Any, ClassVar
+from typing import ClassVar, TypeVar
-from pydantic import BaseModel, Field
+from pydantic import Field, field_validator
from pydantic_extra_types.mac_address import MacAddress
-from anta import GITHUB_SUGGESTION
-from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger
+from anta.custom_types import Interface, Percent, PositiveInteger
from anta.decorators import skip_on_platforms
-from anta.input_models.interfaces import InterfaceState
+from anta.input_models.interfaces import InterfaceDetail, InterfaceState
from anta.models import AntaCommand, AntaTemplate, AntaTest
-from anta.tools import custom_division, format_data, get_failed_logs, get_item, get_value
+from anta.tools import custom_division, format_data, get_item, get_value
BPS_GBPS_CONVERSIONS = 1000000000
+# Using a TypeVar for the InterfaceState model since mypy thinks it's a ClassVar and not a valid type when used in field validators
+T = TypeVar("T", bound=InterfaceState)
+
class VerifyInterfaceUtilization(AntaTest):
"""Verifies that the utilization of interfaces is below a certain threshold.
@@ -60,8 +61,8 @@ class VerifyInterfaceUtilization(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfaceUtilization."""
+ self.result.is_success()
duplex_full = "duplexFull"
- failed_interfaces: dict[str, dict[str, float]] = {}
rates = self.instance_commands[0].json_output
interfaces = self.instance_commands[1].json_output
@@ -77,15 +78,13 @@ class VerifyInterfaceUtilization(AntaTest):
self.logger.debug("Interface %s has been ignored due to null bandwidth value", intf)
continue
+ # If one or more interfaces have a usage above the threshold, test fails.
for bps_rate in ("inBpsRate", "outBpsRate"):
usage = rate[bps_rate] / bandwidth * 100
if usage > self.inputs.threshold:
- failed_interfaces.setdefault(intf, {})[bps_rate] = usage
-
- if not failed_interfaces:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following interfaces have a usage > {self.inputs.threshold}%: {failed_interfaces}")
+ self.result.is_failure(
+ f"Interface: {intf} BPS Rate: {bps_rate} - Usage exceeds the threshold - Expected: < {self.inputs.threshold}% Actual: {usage}%"
+ )
class VerifyInterfaceErrors(AntaTest):
@@ -110,15 +109,12 @@ class VerifyInterfaceErrors(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfaceErrors."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- wrong_interfaces: list[dict[str, dict[str, int]]] = []
for interface, counters in command_output["interfaceErrorCounters"].items():
- if any(value > 0 for value in counters.values()) and all(interface not in wrong_interface for wrong_interface in wrong_interfaces):
- wrong_interfaces.append({interface: counters})
- if not wrong_interfaces:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following interface(s) have non-zero error counters: {wrong_interfaces}")
+ counters_data = [f"{counter}: {value}" for counter, value in counters.items() if value > 0]
+ if counters_data:
+ self.result.is_failure(f"Interface: {interface} - Non-zero error counter(s) - {', '.join(counters_data)}")
class VerifyInterfaceDiscards(AntaTest):
@@ -143,14 +139,12 @@ class VerifyInterfaceDiscards(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfaceDiscards."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- wrong_interfaces: list[dict[str, dict[str, int]]] = []
- for interface, outer_v in command_output["interfaces"].items():
- wrong_interfaces.extend({interface: outer_v} for value in outer_v.values() if value > 0)
- if not wrong_interfaces:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following interfaces have non 0 discard counter(s): {wrong_interfaces}")
+ for interface, interface_data in command_output["interfaces"].items():
+ counters_data = [f"{counter}: {value}" for counter, value in interface_data.items() if value > 0]
+ if counters_data:
+ self.result.is_failure(f"Interface: {interface} - Non-zero discard counter(s): {', '.join(counters_data)}")
class VerifyInterfaceErrDisabled(AntaTest):
@@ -175,12 +169,11 @@ class VerifyInterfaceErrDisabled(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfaceErrDisabled."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"]
- if errdisabled_interfaces:
- self.result.is_failure(f"The following interfaces are in error disabled state: {errdisabled_interfaces}")
- else:
- self.result.is_success()
+ for interface, value in command_output["interfaceStatuses"].items():
+ if value["linkStatus"] == "errdisabled":
+ self.result.is_failure(f"Interface: {interface} - Link status Error disabled")
class VerifyInterfacesStatus(AntaTest):
@@ -226,6 +219,16 @@ class VerifyInterfacesStatus(AntaTest):
"""List of interfaces with their expected state."""
InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState
+ @field_validator("interfaces")
+ @classmethod
+ def validate_interfaces(cls, interfaces: list[T]) -> list[T]:
+ """Validate that 'status' field is provided in each interface."""
+ for interface in interfaces:
+ if interface.status is None:
+ msg = f"{interface} 'status' field missing in the input"
+ raise ValueError(msg)
+ return interfaces
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfacesStatus."""
@@ -242,16 +245,16 @@ class VerifyInterfacesStatus(AntaTest):
# If line protocol status is provided, prioritize checking against both status and line protocol status
if interface.line_protocol_status:
- if interface.status != status or interface.line_protocol_status != proto:
+ if any([interface.status != status, interface.line_protocol_status != proto]):
actual_state = f"Expected: {interface.status}/{interface.line_protocol_status}, Actual: {status}/{proto}"
- self.result.is_failure(f"{interface.name} - {actual_state}")
+ self.result.is_failure(f"{interface.name} - Status mismatch - {actual_state}")
# If line protocol status is not provided and interface status is "up", expect both status and proto to be "up"
# If interface status is not "up", check only the interface status without considering line protocol status
- elif interface.status == "up" and (status != "up" or proto != "up"):
- self.result.is_failure(f"{interface.name} - Expected: up/up, Actual: {status}/{proto}")
+ elif all([interface.status == "up", status != "up" or proto != "up"]):
+ self.result.is_failure(f"{interface.name} - Status mismatch - Expected: up/up, Actual: {status}/{proto}")
elif interface.status != status:
- self.result.is_failure(f"{interface.name} - Expected: {interface.status}, Actual: {status}")
+ self.result.is_failure(f"{interface.name} - Status mismatch - Expected: {interface.status}, Actual: {status}")
class VerifyStormControlDrops(AntaTest):
@@ -278,16 +281,15 @@ class VerifyStormControlDrops(AntaTest):
def test(self) -> None:
"""Main test function for VerifyStormControlDrops."""
command_output = self.instance_commands[0].json_output
- storm_controlled_interfaces: dict[str, dict[str, Any]] = {}
+ storm_controlled_interfaces = []
+ self.result.is_success()
+
for interface, interface_dict in command_output["interfaces"].items():
for traffic_type, traffic_type_dict in interface_dict["trafficTypes"].items():
if "drop" in traffic_type_dict and traffic_type_dict["drop"] != 0:
- storm_controlled_interface_dict = storm_controlled_interfaces.setdefault(interface, {})
- storm_controlled_interface_dict.update({traffic_type: traffic_type_dict["drop"]})
- if not storm_controlled_interfaces:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following interfaces have none 0 storm-control drop counters {storm_controlled_interfaces}")
+ storm_controlled_interfaces.append(f"{traffic_type}: {traffic_type_dict['drop']}")
+ if storm_controlled_interfaces:
+ self.result.is_failure(f"Interface: {interface} - Non-zero storm-control drop counter(s) - {', '.join(storm_controlled_interfaces)}")
class VerifyPortChannels(AntaTest):
@@ -312,15 +314,12 @@ class VerifyPortChannels(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPortChannels."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- po_with_inactive_ports: list[dict[str, str]] = []
- for portchannel, portchannel_dict in command_output["portChannels"].items():
- if len(portchannel_dict["inactivePorts"]) != 0:
- po_with_inactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]})
- if not po_with_inactive_ports:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_inactive_ports}")
+ for port_channel, port_channel_details in command_output["portChannels"].items():
+ # Verify that the no inactive ports in all port channels.
+ if inactive_ports := port_channel_details["inactivePorts"]:
+ self.result.is_failure(f"{port_channel} - Inactive port(s) - {', '.join(inactive_ports.keys())}")
class VerifyIllegalLACP(AntaTest):
@@ -345,16 +344,13 @@ class VerifyIllegalLACP(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIllegalLACP."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- po_with_illegal_lacp: list[dict[str, dict[str, int]]] = []
- for portchannel, portchannel_dict in command_output["portChannels"].items():
- po_with_illegal_lacp.extend(
- {portchannel: interface} for interface, interface_dict in portchannel_dict["interfaces"].items() if interface_dict["illegalRxCount"] != 0
- )
- if not po_with_illegal_lacp:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following port-channels have received illegal LACP packets on the following ports: {po_with_illegal_lacp}")
+ for port_channel, port_channel_dict in command_output["portChannels"].items():
+ for interface, interface_details in port_channel_dict["interfaces"].items():
+ # Verify that the no illegal LACP packets in all port channels.
+ if interface_details["illegalRxCount"] != 0:
+ self.result.is_failure(f"{port_channel} Interface: {interface} - Illegal LACP packets found")
class VerifyLoopbackCount(AntaTest):
@@ -387,23 +383,20 @@ class VerifyLoopbackCount(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoopbackCount."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
loopback_count = 0
- down_loopback_interfaces = []
- for interface in command_output["interfaces"]:
- interface_dict = command_output["interfaces"][interface]
+ for interface, interface_details in command_output["interfaces"].items():
if "Loopback" in interface:
loopback_count += 1
- if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"):
- down_loopback_interfaces.append(interface)
- if loopback_count == self.inputs.number and len(down_loopback_interfaces) == 0:
- self.result.is_success()
- else:
- self.result.is_failure()
- if loopback_count != self.inputs.number:
- self.result.is_failure(f"Found {loopback_count} Loopbacks when expecting {self.inputs.number}")
- elif len(down_loopback_interfaces) != 0: # pragma: no branch
- self.result.is_failure(f"The following Loopbacks are not up: {down_loopback_interfaces}")
+ if (status := interface_details["lineProtocolStatus"]) != "up":
+ self.result.is_failure(f"Interface: {interface} - Invalid line protocol status - Expected: up Actual: {status}")
+
+ if (status := interface_details["interfaceStatus"]) != "connected":
+ self.result.is_failure(f"Interface: {interface} - Invalid interface status - Expected: connected Actual: {status}")
+
+ if loopback_count != self.inputs.number:
+ self.result.is_failure(f"Loopback interface(s) count mismatch: Expected {self.inputs.number} Actual: {loopback_count}")
class VerifySVI(AntaTest):
@@ -428,16 +421,13 @@ class VerifySVI(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySVI."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- down_svis = []
- for interface in command_output["interfaces"]:
- interface_dict = command_output["interfaces"][interface]
- if "Vlan" in interface and not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"):
- down_svis.append(interface)
- if len(down_svis) == 0:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following SVIs are not up: {down_svis}")
+ for interface, int_data in command_output["interfaces"].items():
+ if "Vlan" in interface and (status := int_data["lineProtocolStatus"]) != "up":
+ self.result.is_failure(f"SVI: {interface} - Invalid line protocol status - Expected: up Actual: {status}")
+ if "Vlan" in interface and int_data["interfaceStatus"] != "connected":
+ self.result.is_failure(f"SVI: {interface} - Invalid interface status - Expected: connected Actual: {int_data['interfaceStatus']}")
class VerifyL3MTU(AntaTest):
@@ -482,8 +472,7 @@ class VerifyL3MTU(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyL3MTU."""
- # Parameter to save incorrect interface settings
- wrong_l3mtu_intf: list[dict[str, int]] = []
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
# Set list of interfaces with specific settings
specific_interfaces: list[str] = []
@@ -493,18 +482,18 @@ class VerifyL3MTU(AntaTest):
for interface, values in command_output["interfaces"].items():
if re.findall(r"[a-z]+", interface, re.IGNORECASE)[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "routed":
if interface in specific_interfaces:
- wrong_l3mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface])
+ invalid_mtu = next(
+ (values["mtu"] for custom_data in self.inputs.specific_mtu if values["mtu"] != (expected_mtu := custom_data[interface])), None
+ )
+ if invalid_mtu:
+ self.result.is_failure(f"Interface: {interface} - Incorrect MTU - Expected: {expected_mtu} Actual: {invalid_mtu}")
# Comparison with generic setting
elif values["mtu"] != self.inputs.mtu:
- wrong_l3mtu_intf.append({interface: values["mtu"]})
- if wrong_l3mtu_intf:
- self.result.is_failure(f"Some interfaces do not have correct MTU configured:\n{wrong_l3mtu_intf}")
- else:
- self.result.is_success()
+ self.result.is_failure(f"Interface: {interface} - Incorrect MTU - Expected: {self.inputs.mtu} Actual: {values['mtu']}")
class VerifyIPProxyARP(AntaTest):
- """Verifies if Proxy-ARP is enabled for the provided list of interface(s).
+ """Verifies if Proxy ARP is enabled.
Expected Results
----------------
@@ -522,32 +511,28 @@ class VerifyIPProxyARP(AntaTest):
```
"""
- description = "Verifies if Proxy ARP is enabled."
categories: ClassVar[list[str]] = ["interfaces"]
- commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface", revision=2)]
class Input(AntaTest.Input):
"""Input model for the VerifyIPProxyARP test."""
- interfaces: list[str]
+ interfaces: list[Interface]
"""List of interfaces to be tested."""
- def render(self, template: AntaTemplate) -> list[AntaCommand]:
- """Render the template for each interface in the input list."""
- return [template.render(intf=intf) for intf in self.inputs.interfaces]
-
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIPProxyARP."""
- disabled_intf = []
- for command in self.instance_commands:
- intf = command.params.intf
- if not command.json_output["interfaces"][intf]["proxyArp"]:
- disabled_intf.append(intf)
- if disabled_intf:
- self.result.is_failure(f"The following interface(s) have Proxy-ARP disabled: {disabled_intf}")
- else:
- self.result.is_success()
+ self.result.is_success()
+ command_output = self.instance_commands[0].json_output
+
+ for interface in self.inputs.interfaces:
+ if (interface_detail := get_value(command_output["interfaces"], f"{interface}", separator="..")) is None:
+ self.result.is_failure(f"Interface: {interface} - Not found")
+ continue
+
+ if not interface_detail["proxyArp"]:
+ self.result.is_failure(f"Interface: {interface} - Proxy-ARP disabled")
class VerifyL2MTU(AntaTest):
@@ -586,36 +571,29 @@ class VerifyL2MTU(AntaTest):
"""Default MTU we should have configured on all non-excluded interfaces. Defaults to 9214."""
ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"])
"""A list of L2 interfaces to ignore. Defaults to ["Management", "Loopback", "Vxlan", "Tunnel"]"""
- specific_mtu: list[dict[str, int]] = Field(default=[])
+ specific_mtu: list[dict[Interface, int]] = Field(default=[])
"""A list of dictionary of L2 interfaces with their specific MTU configured"""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyL2MTU."""
- # Parameter to save incorrect interface settings
- wrong_l2mtu_intf: list[dict[str, int]] = []
- command_output = self.instance_commands[0].json_output
- # Set list of interfaces with specific settings
- specific_interfaces: list[str] = []
- if self.inputs.specific_mtu:
- for d in self.inputs.specific_mtu:
- specific_interfaces.extend(d)
- for interface, values in command_output["interfaces"].items():
+ self.result.is_success()
+ interface_output = self.instance_commands[0].json_output["interfaces"]
+ specific_interfaces = {key: value for details in self.inputs.specific_mtu for key, value in details.items()}
+
+ for interface, details in interface_output.items():
catch_interface = re.findall(r"^[e,p][a-zA-Z]+[-,a-zA-Z]*\d+\/*\d*", interface, re.IGNORECASE)
- if len(catch_interface) and catch_interface[0] not in self.inputs.ignored_interfaces and values["forwardingModel"] == "bridged":
+ if catch_interface and catch_interface not in self.inputs.ignored_interfaces and details["forwardingModel"] == "bridged":
if interface in specific_interfaces:
- wrong_l2mtu_intf.extend({interface: values["mtu"]} for custom_data in self.inputs.specific_mtu if values["mtu"] != custom_data[interface])
- # Comparison with generic setting
- elif values["mtu"] != self.inputs.mtu:
- wrong_l2mtu_intf.append({interface: values["mtu"]})
- if wrong_l2mtu_intf:
- self.result.is_failure(f"Some L2 interfaces do not have correct MTU configured:\n{wrong_l2mtu_intf}")
- else:
- self.result.is_success()
+ if (mtu := specific_interfaces[interface]) != (act_mtu := details["mtu"]):
+ self.result.is_failure(f"Interface: {interface} - Incorrect MTU configured - Expected: {mtu} Actual: {act_mtu}")
+
+ elif (act_mtu := details["mtu"]) != self.inputs.mtu:
+ self.result.is_failure(f"Interface: {interface} - Incorrect MTU configured - Expected: {self.inputs.mtu} Actual: {act_mtu}")
class VerifyInterfaceIPv4(AntaTest):
- """Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses.
+ """Verifies the interface IPv4 addresses.
Expected Results
----------------
@@ -636,83 +614,61 @@ class VerifyInterfaceIPv4(AntaTest):
```
"""
- description = "Verifies the interface IPv4 addresses."
categories: ClassVar[list[str]] = ["interfaces"]
- commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface", revision=2)]
class Input(AntaTest.Input):
"""Input model for the VerifyInterfaceIPv4 test."""
- interfaces: list[InterfaceDetail]
+ interfaces: list[InterfaceState]
"""List of interfaces with their details."""
+ InterfaceDetail: ClassVar[type[InterfaceDetail]] = InterfaceDetail
- class InterfaceDetail(BaseModel):
- """Model for an interface detail."""
-
- name: Interface
- """Name of the interface."""
- primary_ip: IPv4Interface
- """Primary IPv4 address in CIDR notation."""
- secondary_ips: list[IPv4Interface] | None = None
- """Optional list of secondary IPv4 addresses in CIDR notation."""
-
- def render(self, template: AntaTemplate) -> list[AntaCommand]:
- """Render the template for each interface in the input list."""
- return [template.render(interface=interface.name) for interface in self.inputs.interfaces]
+ @field_validator("interfaces")
+ @classmethod
+ def validate_interfaces(cls, interfaces: list[T]) -> list[T]:
+ """Validate that 'primary_ip' field is provided in each interface."""
+ for interface in interfaces:
+ if interface.primary_ip is None:
+ msg = f"{interface} 'primary_ip' field missing in the input"
+ raise ValueError(msg)
+ return interfaces
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyInterfaceIPv4."""
self.result.is_success()
- for command in self.instance_commands:
- intf = command.params.interface
- for interface in self.inputs.interfaces:
- if interface.name == intf:
- input_interface_detail = interface
- break
- else:
- self.result.is_failure(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}")
+ command_output = self.instance_commands[0].json_output
+
+ for interface in self.inputs.interfaces:
+ if (interface_detail := get_value(command_output["interfaces"], f"{interface.name}", separator="..")) is None:
+ self.result.is_failure(f"{interface} - Not found")
continue
- input_primary_ip = str(input_interface_detail.primary_ip)
- failed_messages = []
-
- # Check if the interface has an IP address configured
- if not (interface_output := get_value(command.json_output, f"interfaces.{intf}.interfaceAddress")):
- self.result.is_failure(f"For interface `{intf}`, IP address is not configured.")
+ if (ip_address := get_value(interface_detail, "interfaceAddress.primaryIp")) is None:
+ self.result.is_failure(f"{interface} - IP address is not configured")
continue
- primary_ip = get_value(interface_output, "primaryIp")
-
# Combine IP address and subnet for primary IP
- actual_primary_ip = f"{primary_ip['address']}/{primary_ip['maskLen']}"
+ actual_primary_ip = f"{ip_address['address']}/{ip_address['maskLen']}"
# Check if the primary IP address matches the input
- if actual_primary_ip != input_primary_ip:
- failed_messages.append(f"The expected primary IP address is `{input_primary_ip}`, but the actual primary IP address is `{actual_primary_ip}`.")
+ if actual_primary_ip != str(interface.primary_ip):
+ self.result.is_failure(f"{interface} - IP address mismatch - Expected: {interface.primary_ip} Actual: {actual_primary_ip}")
- if (param_secondary_ips := input_interface_detail.secondary_ips) is not None:
- input_secondary_ips = sorted([str(network) for network in param_secondary_ips])
- secondary_ips = get_value(interface_output, "secondaryIpsOrderedList")
+ if interface.secondary_ips:
+ if not (secondary_ips := get_value(interface_detail, "interfaceAddress.secondaryIpsOrderedList")):
+ self.result.is_failure(f"{interface} - Secondary IP address is not configured")
+ continue
- # Combine IP address and subnet for secondary IPs
actual_secondary_ips = sorted([f"{secondary_ip['address']}/{secondary_ip['maskLen']}" for secondary_ip in secondary_ips])
+ input_secondary_ips = sorted([str(ip) for ip in interface.secondary_ips])
- # Check if the secondary IP address is configured
- if not actual_secondary_ips:
- failed_messages.append(
- f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP address is not configured."
+ if actual_secondary_ips != input_secondary_ips:
+ self.result.is_failure(
+ f"{interface} - Secondary IP address mismatch - Expected: {', '.join(input_secondary_ips)} Actual: {', '.join(actual_secondary_ips)}"
)
- # Check if the secondary IP addresses match the input
- elif actual_secondary_ips != input_secondary_ips:
- failed_messages.append(
- f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP addresses are `{actual_secondary_ips}`."
- )
-
- if failed_messages:
- self.result.is_failure(f"For interface `{intf}`, " + " ".join(failed_messages))
-
class VerifyIpVirtualRouterMac(AntaTest):
"""Verifies the IP virtual router MAC address.
@@ -743,13 +699,10 @@ class VerifyIpVirtualRouterMac(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIpVirtualRouterMac."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output["virtualMacs"]
- mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address)
-
- if mac_address_found is None:
- self.result.is_failure(f"IP virtual router MAC address `{self.inputs.mac_address}` is not configured.")
- else:
- self.result.is_success()
+ if get_item(command_output, "macAddress", self.inputs.mac_address) is None:
+ self.result.is_failure(f"IP virtual router MAC address: {self.inputs.mac_address} - Not configured")
class VerifyInterfacesSpeed(AntaTest):
@@ -788,20 +741,19 @@ class VerifyInterfacesSpeed(AntaTest):
class Input(AntaTest.Input):
"""Inputs for the VerifyInterfacesSpeed test."""
- interfaces: list[InterfaceDetail]
- """List of interfaces to be tested"""
+ interfaces: list[InterfaceState]
+ """List of interfaces with their expected state."""
+ InterfaceDetail: ClassVar[type[InterfaceDetail]] = InterfaceDetail
- 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."""
+ @field_validator("interfaces")
+ @classmethod
+ def validate_interfaces(cls, interfaces: list[T]) -> list[T]:
+ """Validate that 'speed' field is provided in each interface."""
+ for interface in interfaces:
+ if interface.speed is None:
+ msg = f"{interface} 'speed' field missing in the input"
+ raise ValueError(msg)
+ return interfaces
@AntaTest.anta_test
def test(self) -> None:
@@ -811,40 +763,27 @@ class VerifyInterfacesSpeed(AntaTest):
# 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.")
+ if (interface_detail := get_value(command_output["interfaces"], f"{interface.name}", separator="..")) is None:
+ self.result.is_failure(f"{interface} - Not found")
continue
- auto_negotiation = interface_output.get("autoNegotiate")
- actual_lanes = interface_output.get("lanes")
+ # Verifies the bandwidth
+ if (speed := interface_detail.get("bandwidth")) != interface.speed * BPS_GBPS_CONVERSIONS:
+ self.result.is_failure(
+ f"{interface} - Bandwidth mismatch - Expected: {interface.speed}Gbps Actual: {custom_division(speed, BPS_GBPS_CONVERSIONS)}Gbps"
+ )
- # 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,
- }
+ # Verifies the duplex mode
+ if (duplex := interface_detail.get("duplex")) != "duplexFull":
+ self.result.is_failure(f"{interface} - Duplex mode mismatch - Expected: duplexFull Actual: {duplex}")
- # 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,
- }
+ # Verifies the auto-negotiation as success if specified
+ if interface.auto and (auto_negotiation := interface_detail.get("autoNegotiate")) != "success":
+ self.result.is_failure(f"{interface} - Auto-negotiation mismatch - Expected: success Actual: {auto_negotiation}")
- # 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")
+ # Verifies the communication lanes if specified
+ if interface.lanes and (lanes := interface_detail.get("lanes")) != interface.lanes:
+ self.result.is_failure(f"{interface} - Data lanes count mismatch - Expected: {interface.lanes} Actual: {lanes}")
class VerifyLACPInterfacesStatus(AntaTest):
@@ -891,6 +830,16 @@ class VerifyLACPInterfacesStatus(AntaTest):
"""List of interfaces with their expected state."""
InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState
+ @field_validator("interfaces")
+ @classmethod
+ def validate_interfaces(cls, interfaces: list[T]) -> list[T]:
+ """Validate that 'portchannel' field is provided in each interface."""
+ for interface in interfaces:
+ if interface.portchannel is None:
+ msg = f"{interface} 'portchannel' field missing in the input"
+ raise ValueError(msg)
+ return interfaces
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLACPInterfacesStatus."""
diff --git a/anta/tests/lanz.py b/anta/tests/lanz.py
index 0995af7..33e5472 100644
--- a/anta/tests/lanz.py
+++ b/anta/tests/lanz.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 LANZ tests."""
diff --git a/anta/tests/logging.py b/anta/tests/logging.py
index c391947..f13860e 100644
--- a/anta/tests/logging.py
+++ b/anta/tests/logging.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various logging tests.
@@ -14,13 +14,13 @@ import re
from ipaddress import IPv4Address
from typing import TYPE_CHECKING, ClassVar
-from anta.models import AntaCommand, AntaTest
+from anta.custom_types import LogSeverityLevel
+from anta.input_models.logging import LoggingQuery
+from anta.models import AntaCommand, AntaTemplate, AntaTest
if TYPE_CHECKING:
import logging
- from anta.models import AntaTemplate
-
def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
"""Parse `show logging` output and gets operational logging states used in the tests in this module.
@@ -43,6 +43,35 @@ def _get_logging_states(logger: logging.Logger, command_output: str) -> str:
return log_states
+class VerifySyslogLogging(AntaTest):
+ """Verifies if syslog logging is enabled.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if syslog logging is enabled.
+ * Failure: The test will fail if syslog logging is disabled.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.logging:
+ - VerifySyslogLogging:
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["logging"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySyslogLogging."""
+ self.result.is_success()
+ log_output = self.instance_commands[0].text_output
+
+ if "Syslog logging: enabled" not in _get_logging_states(self.logger, log_output):
+ self.result.is_failure("Syslog logging is disabled")
+
+
class VerifyLoggingPersistent(AntaTest):
"""Verifies if logging persistent is enabled and logs are saved in flash.
@@ -117,7 +146,7 @@ class VerifyLoggingSourceIntf(AntaTest):
if re.search(pattern, _get_logging_states(self.logger, output)):
self.result.is_success()
else:
- self.result.is_failure(f"Source-interface '{self.inputs.interface}' is not configured in VRF {self.inputs.vrf}")
+ self.result.is_failure(f"Source-interface: {self.inputs.interface} VRF: {self.inputs.vrf} - Not configured")
class VerifyLoggingHosts(AntaTest):
@@ -164,7 +193,7 @@ class VerifyLoggingHosts(AntaTest):
if not not_configured:
self.result.is_success()
else:
- self.result.is_failure(f"Syslog servers {not_configured} are not configured in VRF {self.inputs.vrf}")
+ self.result.is_failure(f"Syslog servers {', '.join(not_configured)} are not configured in VRF {self.inputs.vrf}")
class VerifyLoggingLogsGeneration(AntaTest):
@@ -172,35 +201,43 @@ class VerifyLoggingLogsGeneration(AntaTest):
This test performs the following checks:
- 1. Sends a test log message at the **informational** level
- 2. Retrieves the most recent logs (last 30 seconds)
- 3. Verifies that the test message was successfully logged
-
- !!! warning
- EOS logging buffer should be set to severity level `informational` or higher for this test to work.
+ 1. Sends a test log message at the specified severity log level.
+ 2. Retrieves the most recent logs (last 30 seconds).
+ 3. Verifies that the test message was successfully logged.
Expected Results
----------------
* Success: If logs are being generated and the test message is found in recent logs.
* Failure: If any of the following occur:
- - The test message is not found in recent logs
- - The logging system is not capturing new messages
- - No logs are being generated
+ - The test message is not found in recent logs.
+ - The logging system is not capturing new messages.
+ - No logs are being generated.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingLogsGeneration:
+ severity_level: informational
```
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
- AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"),
- AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
+ AntaTemplate(template="send log level {severity_level} message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"),
+ AntaTemplate(template="show logging {severity_level} last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
]
+ class Input(AntaTest.Input):
+ """Input model for the VerifyLoggingLogsGeneration test."""
+
+ severity_level: LogSeverityLevel = "informational"
+ """Log severity level. Defaults to informational."""
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ """Render the template for log severity level in the input."""
+ return [template.render(severity_level=self.inputs.severity_level)]
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingLogsGeneration."""
@@ -219,37 +256,45 @@ class VerifyLoggingHostname(AntaTest):
This test performs the following checks:
- 1. Retrieves the device's configured FQDN
- 2. Sends a test log message at the **informational** level
- 3. Retrieves the most recent logs (last 30 seconds)
- 4. Verifies that the test message includes the complete FQDN of the device
-
- !!! warning
- EOS logging buffer should be set to severity level `informational` or higher for this test to work.
+ 1. Retrieves the device's configured FQDN.
+ 2. Sends a test log message at the specified severity log level.
+ 3. Retrieves the most recent logs (last 30 seconds).
+ 4. Verifies that the test message includes the complete FQDN of the device.
Expected Results
----------------
* Success: If logs are generated with the device's complete FQDN.
* Failure: If any of the following occur:
- - The test message is not found in recent logs
- - The log message does not include the device's FQDN
- - The FQDN in the log message doesn't match the configured FQDN
+ - The test message is not found in recent logs.
+ - The log message does not include the device's FQDN.
+ - The FQDN in the log message doesn't match the configured FQDN.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingHostname:
+ severity_level: informational
```
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
AntaCommand(command="show hostname", revision=1),
- AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation", ofmt="text"),
- AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
+ AntaTemplate(template="send log level {severity_level} message ANTA VerifyLoggingHostname validation", ofmt="text"),
+ AntaTemplate(template="show logging {severity_level} last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
]
+ class Input(AntaTest.Input):
+ """Input model for the VerifyLoggingHostname test."""
+
+ severity_level: LogSeverityLevel = "informational"
+ """Log severity level. Defaults to informational."""
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ """Render the template for log severity level in the input."""
+ return [template.render(severity_level=self.inputs.severity_level)]
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingHostname."""
@@ -274,37 +319,45 @@ class VerifyLoggingTimestamp(AntaTest):
This test performs the following checks:
- 1. Sends a test log message at the **informational** level
- 2. Retrieves the most recent logs (last 30 seconds)
- 3. Verifies that the test message is present with a high-resolution RFC3339 timestamp format
- - Example format: `2024-01-25T15:30:45.123456+00:00`
- - Includes microsecond precision
- - Contains timezone offset
-
- !!! warning
- EOS logging buffer should be set to severity level `informational` or higher for this test to work.
+ 1. Sends a test log message at the specified severity log level.
+ 2. Retrieves the most recent logs (last 30 seconds).
+ 3. Verifies that the test message is present with a high-resolution RFC3339 timestamp format.
+ - Example format: `2024-01-25T15:30:45.123456+00:00`.
+ - Includes microsecond precision.
+ - Contains timezone offset.
Expected Results
----------------
* Success: If logs are generated with the correct high-resolution RFC3339 timestamp format.
* Failure: If any of the following occur:
- - The test message is not found in recent logs
- - The timestamp format does not match the expected RFC3339 format
+ - The test message is not found in recent logs.
+ - The timestamp format does not match the expected RFC3339 format.
Examples
--------
```yaml
anta.tests.logging:
- VerifyLoggingTimestamp:
+ severity_level: informational
```
"""
categories: ClassVar[list[str]] = ["logging"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
- AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
- AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
+ AntaTemplate(template="send log level {severity_level} message ANTA VerifyLoggingTimestamp validation", ofmt="text"),
+ AntaTemplate(template="show logging {severity_level} last 30 seconds | grep ANTA", ofmt="text", use_cache=False),
]
+ class Input(AntaTest.Input):
+ """Input model for the VerifyLoggingTimestamp test."""
+
+ severity_level: LogSeverityLevel = "informational"
+ """Log severity level. Defaults to informational."""
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ """Render the template for log severity level in the input."""
+ return [template.render(severity_level=self.inputs.severity_level)]
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyLoggingTimestamp."""
@@ -381,3 +434,53 @@ class VerifyLoggingErrors(AntaTest):
self.result.is_success()
else:
self.result.is_failure("Device has reported syslog messages with a severity of ERRORS or higher")
+
+
+class VerifyLoggingEntries(AntaTest):
+ """Verifies that the expected log string is present in the last specified log messages.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if the expected log string for the mentioned severity level is present in the last specified log messages.
+ * Failure: The test will fail if the specified log string is not present in the last specified log messages.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.logging:
+ - VerifyLoggingEntries:
+ logging_entries:
+ - regex_match: ".ACCOUNTING-5-EXEC: cvpadmin ssh."
+ last_number_messages: 30
+ severity_level: alerts
+ - regex_match: ".SPANTREE-6-INTERFACE_ADD:."
+ last_number_messages: 10
+ severity_level: critical
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["logging"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
+ AntaTemplate(template="show logging {last_number_messages} {severity_level}", ofmt="text", use_cache=False)
+ ]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyLoggingEntries test."""
+
+ logging_entries: list[LoggingQuery]
+ """List of logging entries and regex match."""
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ """Render the template for last number messages and log severity level in the input."""
+ return [template.render(last_number_messages=entry.last_number_messages, severity_level=entry.severity_level) for entry in self.inputs.logging_entries]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyLoggingEntries."""
+ self.result.is_success()
+ for command_output, logging_entry in zip(self.instance_commands, self.inputs.logging_entries):
+ output = command_output.text_output
+ if not re.search(logging_entry.regex_match, output):
+ self.result.is_failure(
+ f"Pattern: {logging_entry.regex_match} - Not found in last {logging_entry.last_number_messages} {logging_entry.severity_level} log entries"
+ )
diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py
index e353420..7217d57 100644
--- a/anta/tests/mlag.py
+++ b/anta/tests/mlag.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 Multi-chassis Link Aggregation (MLAG) tests."""
@@ -22,10 +22,8 @@ class VerifyMlagStatus(AntaTest):
Expected Results
----------------
- * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected',
- peer-link status and local interface status are 'up'.
- * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected',
- peer-link status or local interface status are not 'up'.
+ * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', peer-link status and local interface status are 'up'.
+ * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', peer-link status or local interface status are not 'up'.
* Skipped: The test will be skipped if MLAG is 'disabled'.
Examples
@@ -42,21 +40,25 @@ class VerifyMlagStatus(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagStatus."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
+
+ # Skipping the test if MLAG is disabled
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
return
- keys_to_verify = ["state", "negStatus", "localIntfStatus", "peerLinkStatus"]
- verified_output = {key: get_value(command_output, key) for key in keys_to_verify}
- if (
- verified_output["state"] == "active"
- and verified_output["negStatus"] == "connected"
- and verified_output["localIntfStatus"] == "up"
- and verified_output["peerLinkStatus"] == "up"
- ):
- self.result.is_success()
- else:
- self.result.is_failure(f"MLAG status is not OK: {verified_output}")
+
+ # Verifies the negotiation status
+ if (neg_status := command_output["negStatus"]) != "connected":
+ self.result.is_failure(f"MLAG negotiation status mismatch - Expected: connected Actual: {neg_status}")
+
+ # Verifies the local interface interface status
+ if (intf_state := command_output["localIntfStatus"]) != "up":
+ self.result.is_failure(f"Operational state of the MLAG local interface is not correct - Expected: up Actual: {intf_state}")
+
+ # Verifies the peerLinkStatus
+ if (peer_link_state := command_output["peerLinkStatus"]) != "up":
+ self.result.is_failure(f"Operational state of the MLAG peer link is not correct - Expected: up Actual: {peer_link_state}")
class VerifyMlagInterfaces(AntaTest):
@@ -82,14 +84,19 @@ class VerifyMlagInterfaces(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagInterfaces."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
+
+ # Skipping the test if MLAG is disabled
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
return
- if command_output["mlagPorts"]["Inactive"] == 0 and command_output["mlagPorts"]["Active-partial"] == 0:
- self.result.is_success()
- else:
- self.result.is_failure(f"MLAG status is not OK: {command_output['mlagPorts']}")
+
+ # Verifies the Inactive and Active-partial ports
+ inactive_ports = command_output["mlagPorts"]["Inactive"]
+ partial_active_ports = command_output["mlagPorts"]["Active-partial"]
+ if inactive_ports != 0 or partial_active_ports != 0:
+ self.result.is_failure(f"MLAG status is not ok - Inactive Ports: {inactive_ports} Partial Active Ports: {partial_active_ports}")
class VerifyMlagConfigSanity(AntaTest):
@@ -116,16 +123,21 @@ class VerifyMlagConfigSanity(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagConfigSanity."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
+
+ # Skipping the test if MLAG is disabled
if command_output["mlagActive"] is False:
self.result.is_skipped("MLAG is disabled")
return
- keys_to_verify = ["globalConfiguration", "interfaceConfiguration"]
- verified_output = {key: get_value(command_output, key) for key in keys_to_verify}
- if not any(verified_output.values()):
- self.result.is_success()
- else:
- self.result.is_failure(f"MLAG config-sanity returned inconsistencies: {verified_output}")
+
+ # Verifies the globalConfiguration config-sanity
+ if get_value(command_output, "globalConfiguration"):
+ self.result.is_failure("MLAG config-sanity found in global configuration")
+
+ # Verifies the interfaceConfiguration config-sanity
+ if get_value(command_output, "interfaceConfiguration"):
+ self.result.is_failure("MLAG config-sanity found in interface configuration")
class VerifyMlagReloadDelay(AntaTest):
@@ -161,17 +173,21 @@ class VerifyMlagReloadDelay(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagReloadDelay."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
+
+ # Skipping the test if MLAG is disabled
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
return
- keys_to_verify = ["reloadDelay", "reloadDelayNonMlag"]
- verified_output = {key: get_value(command_output, key) for key in keys_to_verify}
- if verified_output["reloadDelay"] == self.inputs.reload_delay and verified_output["reloadDelayNonMlag"] == self.inputs.reload_delay_non_mlag:
- self.result.is_success()
- else:
- self.result.is_failure(f"The reload-delay parameters are not configured properly: {verified_output}")
+ # Verifies the reloadDelay
+ if (reload_delay := get_value(command_output, "reloadDelay")) != self.inputs.reload_delay:
+ self.result.is_failure(f"MLAG reload-delay mismatch - Expected: {self.inputs.reload_delay}s Actual: {reload_delay}s")
+
+ # Verifies the reloadDelayNonMlag
+ if (non_mlag_reload_delay := get_value(command_output, "reloadDelayNonMlag")) != self.inputs.reload_delay_non_mlag:
+ self.result.is_failure(f"Delay for non-MLAG ports mismatch - Expected: {self.inputs.reload_delay_non_mlag}s Actual: {non_mlag_reload_delay}s")
class VerifyMlagDualPrimary(AntaTest):
@@ -214,25 +230,37 @@ class VerifyMlagDualPrimary(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMlagDualPrimary."""
+ self.result.is_success()
errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none"
command_output = self.instance_commands[0].json_output
+
+ # Skipping the test if MLAG is disabled
if command_output["state"] == "disabled":
self.result.is_skipped("MLAG is disabled")
return
+
+ # Verifies the dualPrimaryDetectionState
if command_output["dualPrimaryDetectionState"] == "disabled":
self.result.is_failure("Dual-primary detection is disabled")
return
- keys_to_verify = ["detail.dualPrimaryDetectionDelay", "detail.dualPrimaryAction", "dualPrimaryMlagRecoveryDelay", "dualPrimaryNonMlagRecoveryDelay"]
- verified_output = {key: get_value(command_output, key) for key in keys_to_verify}
- if (
- verified_output["detail.dualPrimaryDetectionDelay"] == self.inputs.detection_delay
- and verified_output["detail.dualPrimaryAction"] == errdisabled_action
- and verified_output["dualPrimaryMlagRecoveryDelay"] == self.inputs.recovery_delay
- and verified_output["dualPrimaryNonMlagRecoveryDelay"] == self.inputs.recovery_delay_non_mlag
- ):
- self.result.is_success()
- else:
- self.result.is_failure(f"The dual-primary parameters are not configured properly: {verified_output}")
+
+ # Verifies the dualPrimaryAction
+ if (primary_action := get_value(command_output, "detail.dualPrimaryAction")) != errdisabled_action:
+ self.result.is_failure(f"Dual-primary action mismatch - Expected: {errdisabled_action} Actual: {primary_action}")
+
+ # Verifies the dualPrimaryDetectionDelay
+ if (detection_delay := get_value(command_output, "detail.dualPrimaryDetectionDelay")) != self.inputs.detection_delay:
+ self.result.is_failure(f"Dual-primary detection delay mismatch - Expected: {self.inputs.detection_delay} Actual: {detection_delay}")
+
+ # Verifies the dualPrimaryMlagRecoveryDelay
+ if (recovery_delay := get_value(command_output, "dualPrimaryMlagRecoveryDelay")) != self.inputs.recovery_delay:
+ self.result.is_failure(f"Dual-primary MLAG recovery delay mismatch - Expected: {self.inputs.recovery_delay} Actual: {recovery_delay}")
+
+ # Verifies the dualPrimaryNonMlagRecoveryDelay
+ if (recovery_delay_non_mlag := get_value(command_output, "dualPrimaryNonMlagRecoveryDelay")) != self.inputs.recovery_delay_non_mlag:
+ self.result.is_failure(
+ f"Dual-primary non MLAG recovery delay mismatch - Expected: {self.inputs.recovery_delay_non_mlag} Actual: {recovery_delay_non_mlag}"
+ )
class VerifyMlagPrimaryPriority(AntaTest):
@@ -278,10 +306,8 @@ class VerifyMlagPrimaryPriority(AntaTest):
# Check MLAG state
if mlag_state != "primary":
- self.result.is_failure("The device is not set as MLAG primary.")
+ self.result.is_failure("The device is not set as MLAG primary")
# Check primary priority
if primary_priority != self.inputs.primary_priority:
- self.result.is_failure(
- f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.",
- )
+ self.result.is_failure(f"MLAG primary priority mismatch - Expected: {self.inputs.primary_priority} Actual: {primary_priority}")
diff --git a/anta/tests/multicast.py b/anta/tests/multicast.py
index f6e84ba..fe09c94 100644
--- a/anta/tests/multicast.py
+++ b/anta/tests/multicast.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 multicast and IGMP tests."""
@@ -51,12 +51,12 @@ class VerifyIGMPSnoopingVlans(AntaTest):
self.result.is_success()
for vlan, enabled in self.inputs.vlans.items():
if str(vlan) not in command_output["vlans"]:
- self.result.is_failure(f"Supplied vlan {vlan} is not present on the device.")
+ self.result.is_failure(f"Supplied vlan {vlan} is not present on the device")
continue
-
+ expected_state = "enabled" if enabled else "disabled"
igmp_state = command_output["vlans"][str(vlan)]["igmpSnoopingState"]
- if igmp_state != "enabled" if enabled else igmp_state != "disabled":
- self.result.is_failure(f"IGMP state for vlan {vlan} is {igmp_state}")
+ if igmp_state != expected_state:
+ self.result.is_failure(f"VLAN{vlan} - Incorrect IGMP state - Expected: {expected_state} Actual: {igmp_state}")
class VerifyIGMPSnoopingGlobal(AntaTest):
@@ -91,5 +91,6 @@ class VerifyIGMPSnoopingGlobal(AntaTest):
command_output = self.instance_commands[0].json_output
self.result.is_success()
igmp_state = command_output["igmpSnoopingState"]
- if igmp_state != "enabled" if self.inputs.enabled else igmp_state != "disabled":
- self.result.is_failure(f"IGMP state is not valid: {igmp_state}")
+ expected_state = "enabled" if self.inputs.enabled else "disabled"
+ if igmp_state != expected_state:
+ self.result.is_failure(f"IGMP state is not valid - Expected: {expected_state} Actual: {igmp_state}")
diff --git a/anta/tests/path_selection.py b/anta/tests/path_selection.py
index 15b06ae..0599ecd 100644
--- a/anta/tests/path_selection.py
+++ b/anta/tests/path_selection.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -7,12 +7,10 @@
# 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.input_models.path_selection import DpsPath
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import get_value
@@ -50,7 +48,7 @@ class VerifyPathsHealth(AntaTest):
# 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.")
+ self.result.is_failure("No path configured for router path-selection")
return
# Check the state of each path
@@ -61,25 +59,33 @@ class VerifyPathsHealth(AntaTest):
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}`.")
+ expected_state = ["ipsecEstablished", "routeResolved"]
+ if path_state not in expected_state:
+ self.result.is_failure(f"Peer: {peer} Path Group: {group} - Invalid path state - Expected: {', '.join(expected_state)} Actual: {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`.")
+ self.result.is_failure(f"Peer: {peer} Path Group {group} - Telemetry state inactive")
class VerifySpecificPath(AntaTest):
- """Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection.
+ """Verifies the DPS path and telemetry state of an IPv4 peer.
- The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry.
+ This test performs the following checks:
+
+ 1. Verifies that the specified peer is configured.
+ 2. Verifies that the specified path group is found.
+ 3. For each specified DPS path:
+ - Verifies that the expected source and destination address matches the expected.
+ - Verifies that the state is `ipsecEstablished` or `routeResolved`.
+ - Verifies that the telemetry state is `active`.
Expected Results
----------------
- * Success: The test will pass if the path state under router path-selection is either 'IPsec established' or 'Resolved'
+ * Success: The test will pass if the path state under router path-selection is either 'IPsecEstablished' 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'.
+ * Failure: The test will fail if router path selection or the peer is not configured or if the path state is not 'IPsec established' or 'Resolved',
+ or the telemetry state is 'inactive'.
Examples
--------
@@ -95,36 +101,15 @@ class VerifySpecificPath(AntaTest):
"""
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)
- ]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifySpecificPath test."""
- paths: list[RouterPath]
+ paths: list[DpsPath]
"""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
- ]
+ RouterPath: ClassVar[type[DpsPath]] = DpsPath
+ """To maintain backward compatibility."""
@skip_on_platforms(["cEOSLab", "vEOS-lab"])
@AntaTest.anta_test
@@ -132,28 +117,42 @@ class VerifySpecificPath(AntaTest):
"""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", [])
+ command_output = self.instance_commands[0].json_output
+ # If the dpsPeers details are not found in the command output, the test fails.
+ if not (dps_peers_details := get_value(command_output, "dpsPeers")):
+ self.result.is_failure("Router path-selection not configured")
+ return
+
+ # Iterating on each DPS peer mentioned in the inputs.
+ for dps_path in self.inputs.paths:
+ peer = str(dps_path.peer)
+ peer_details = dps_peers_details.get(peer, {})
# 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}`.")
+ if not peer_details:
+ self.result.is_failure(f"{dps_path} - Peer not found")
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")
+ path_group = dps_path.path_group
+ source = str(dps_path.source_address)
+ destination = str(dps_path.destination_address)
+ path_group_details = get_value(peer_details, f"dpsGroups..{path_group}..dpsPaths", separator="..")
+ # If the expected path group is not found for the peer, the test fails.
+ if not path_group_details:
+ self.result.is_failure(f"{dps_path} - No DPS path found for this peer and path group")
+ continue
+
+ path_data = next((path for path in path_group_details.values() if (path.get("source") == source and path.get("destination") == destination)), None)
+ # Source and destination address do not match, the test fails.
+ if not path_data:
+ self.result.is_failure(f"{dps_path} - No path matching the source and destination found")
+ continue
+
+ path_state = path_data.get("state")
+ session = get_value(path_data, "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}`.")
+ self.result.is_failure(f"{dps_path} - Invalid state path - Expected: ipsecEstablished, routeResolved Actual: {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`."
- )
+ self.result.is_failure(f"{dps_path} - Telemetry state inactive for this path")
diff --git a/anta/tests/profiles.py b/anta/tests/profiles.py
index 93edacd..1279948 100644
--- a/anta/tests/profiles.py
+++ b/anta/tests/profiles.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 ASIC profile tests."""
@@ -51,7 +51,7 @@ class VerifyUnifiedForwardingTableMode(AntaTest):
if command_output["uftMode"] == str(self.inputs.mode):
self.result.is_success()
else:
- self.result.is_failure(f"Device is not running correct UFT mode (expected: {self.inputs.mode} / running: {command_output['uftMode']})")
+ self.result.is_failure(f"Not running the correct UFT mode - Expected: {self.inputs.mode} Actual: {command_output['uftMode']}")
class VerifyTcamProfile(AntaTest):
diff --git a/anta/tests/ptp.py b/anta/tests/ptp.py
index 687f175..309871b 100644
--- a/anta/tests/ptp.py
+++ b/anta/tests/ptp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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 PTP tests."""
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
class VerifyPtpModeStatus(AntaTest):
- """Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC).
+ """Verifies that the device is configured as a PTP Boundary Clock.
Expected Results
----------------
@@ -33,7 +33,6 @@ class VerifyPtpModeStatus(AntaTest):
```
"""
- description = "Verifies that the device is configured as a PTP Boundary Clock."
categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@@ -48,13 +47,13 @@ class VerifyPtpModeStatus(AntaTest):
return
if ptp_mode != "ptpBoundaryClock":
- self.result.is_failure(f"The device is not configured as a PTP Boundary Clock: '{ptp_mode}'")
+ self.result.is_failure(f"Not configured as a PTP Boundary Clock - Actual: {ptp_mode}")
else:
self.result.is_success()
class VerifyPtpGMStatus(AntaTest):
- """Verifies that the device is locked to a valid Precision Time Protocol (PTP) Grandmaster (GM).
+ """Verifies that the device is locked to a valid PTP Grandmaster.
To test PTP failover, re-run the test with a secondary GMID configured.
@@ -79,7 +78,6 @@ class VerifyPtpGMStatus(AntaTest):
gmid: str
"""Identifier of the Grandmaster to which the device should be locked."""
- description = "Verifies that the device is locked to a valid PTP Grandmaster."
categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@@ -87,22 +85,19 @@ class VerifyPtpGMStatus(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyPtpGMStatus."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None:
self.result.is_skipped("PTP is not configured")
return
- if ptp_clock_summary["gmClockIdentity"] != self.inputs.gmid:
- self.result.is_failure(
- f"The device is locked to the following Grandmaster: '{ptp_clock_summary['gmClockIdentity']}', which differ from the expected one.",
- )
- else:
- self.result.is_success()
+ if (act_gmid := ptp_clock_summary["gmClockIdentity"]) != self.inputs.gmid:
+ self.result.is_failure(f"The device is locked to the incorrect Grandmaster - Expected: {self.inputs.gmid} Actual: {act_gmid}")
class VerifyPtpLockStatus(AntaTest):
- """Verifies that the device was locked to the upstream Precision Time Protocol (PTP) Grandmaster (GM) in the last minute.
+ """Verifies that the device was locked to the upstream PTP GM in the last minute.
Expected Results
----------------
@@ -118,7 +113,6 @@ class VerifyPtpLockStatus(AntaTest):
```
"""
- description = "Verifies that the device was locked to the upstream PTP GM in the last minute."
categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@@ -136,13 +130,13 @@ class VerifyPtpLockStatus(AntaTest):
time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"]
if time_difference >= threshold:
- self.result.is_failure(f"The device lock is more than {threshold}s old: {time_difference}s")
+ self.result.is_failure(f"Lock is more than {threshold}s old - Actual: {time_difference}s")
else:
self.result.is_success()
class VerifyPtpOffset(AntaTest):
- """Verifies that the Precision Time Protocol (PTP) timing offset is within +/- 1000ns from the master clock.
+ """Verifies that the PTP timing offset is within +/- 1000ns from the master clock.
Expected Results
----------------
@@ -158,7 +152,6 @@ class VerifyPtpOffset(AntaTest):
```
"""
- description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock."
categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)]
@@ -167,9 +160,9 @@ class VerifyPtpOffset(AntaTest):
def test(self) -> None:
"""Main test function for VerifyPtpOffset."""
threshold = 1000
- offset_interfaces: dict[str, list[int]] = {}
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
-
+ offset_interfaces: dict[str, list[int]] = {}
if not command_output["ptpMonitorData"]:
self.result.is_skipped("PTP is not configured")
return
@@ -178,14 +171,12 @@ class VerifyPtpOffset(AntaTest):
if abs(interface["offsetFromMaster"]) > threshold:
offset_interfaces.setdefault(interface["intf"], []).append(interface["offsetFromMaster"])
- if offset_interfaces:
- self.result.is_failure(f"The device timing offset from master is greater than +/- {threshold}ns: {offset_interfaces}")
- else:
- self.result.is_success()
+ for interface, data in offset_interfaces.items():
+ self.result.is_failure(f"Interface: {interface} - Timing offset from master is greater than +/- {threshold}ns: Actual: {', '.join(map(str, data))}")
class VerifyPtpPortModeStatus(AntaTest):
- """Verifies that all interfaces are in a valid Precision Time Protocol (PTP) state.
+ """Verifies the PTP interfaces state.
The interfaces can be in one of the following state: Master, Slave, Passive, or Disabled.
@@ -202,7 +193,6 @@ class VerifyPtpPortModeStatus(AntaTest):
```
"""
- description = "Verifies the PTP interfaces state."
categories: ClassVar[list[str]] = ["ptp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)]
@@ -227,4 +217,4 @@ class VerifyPtpPortModeStatus(AntaTest):
if not invalid_interfaces:
self.result.is_success()
else:
- self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'")
+ self.result.is_failure(f"The following interface(s) are not in a valid PTP state: {', '.join(invalid_interfaces)}")
diff --git a/anta/tests/routing/__init__.py b/anta/tests/routing/__init__.py
index d4b3786..85ca1ab 100644
--- a/anta/tests/routing/__init__.py
+++ b/anta/tests/routing/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Package related to routing tests."""
diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py
index 2a140dd..bfbcb7f 100644
--- a/anta/tests/routing/bgp.py
+++ b/anta/tests/routing/bgp.py
@@ -1,23 +1,25 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 BGP tests."""
-# Mypy does not understand AntaTest.Input typing
+# pylint: disable=too-many-lines
# mypy: disable-error-code=attr-defined
from __future__ import annotations
-from typing import ClassVar, TypeVar
+from typing import Any, ClassVar, TypeVar
-from pydantic import field_validator
+from pydantic import PositiveInt, field_validator
-from anta.input_models.routing.bgp import BgpAddressFamily, BgpAfi, BgpNeighbor, BgpPeer, VxlanEndpoint
+from anta.input_models.routing.bgp import BgpAddressFamily, BgpAfi, BgpNeighbor, BgpPeer, BgpRoute, BgpVrf, VxlanEndpoint
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.tools import format_data, get_item, get_value
# Using a TypeVar for the BgpPeer model since mypy thinks it's a ClassVar and not a valid type when used in field validators
T = TypeVar("T", bound=BgpPeer)
+# TODO: Refactor to reduce the number of lines in this module later
+
def _check_bgp_neighbor_capability(capability_status: dict[str, bool]) -> bool:
"""Check if a BGP neighbor capability is advertised, received, and enabled.
@@ -129,7 +131,7 @@ class VerifyBGPPeerCount(AntaTest):
# Check if the count matches the expected count
if address_family.num_peers != peer_count:
- self.result.is_failure(f"{address_family} - Expected: {address_family.num_peers}, Actual: {peer_count}")
+ self.result.is_failure(f"{address_family} - Peer count mismatch - Expected: {address_family.num_peers} Actual: {peer_count}")
class VerifyBGPPeersHealth(AntaTest):
@@ -140,7 +142,7 @@ class VerifyBGPPeersHealth(AntaTest):
1. Validates that the VRF is configured.
2. Checks if there are any peers for the given AFI/SAFI.
3. For each relevant peer:
- - Verifies that the BGP session is in the `Established` state.
+ - Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`.
- Confirms that the AFI/SAFI state is `negotiated`.
- Checks that both input and output TCP message queues are empty.
Can be disabled by setting `check_tcp_queues` to `False`.
@@ -151,7 +153,8 @@ class VerifyBGPPeersHealth(AntaTest):
* Failure: If any of the following occur:
- The specified VRF is not configured.
- No peers are found for a given AFI/SAFI.
- - Any BGP session is not in the `Established` state.
+ - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by
+ the `minimum_established_time`.
- The AFI/SAFI state is not 'negotiated' for any peer.
- Any TCP message queue (input or output) is not empty when `check_tcp_queues` is `True` (default).
@@ -161,6 +164,7 @@ class VerifyBGPPeersHealth(AntaTest):
anta.tests.routing:
bgp:
- VerifyBGPPeersHealth:
+ minimum_established_time: 10000
address_families:
- afi: "evpn"
- afi: "ipv4"
@@ -179,6 +183,8 @@ class VerifyBGPPeersHealth(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyBGPPeersHealth test."""
+ minimum_established_time: PositiveInt | None = None
+ """Minimum established time (seconds) for all the BGP sessions."""
address_families: list[BgpAddressFamily]
"""List of BGP address families."""
BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi
@@ -208,9 +214,13 @@ class VerifyBGPPeersHealth(AntaTest):
for peer in relevant_peers:
# Check if the BGP session is established
if peer["state"] != "Established":
- self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Session state is not established - State: {peer['state']}")
+ self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Incorrect session state - Expected: Established Actual: {peer['state']}")
continue
+ if self.inputs.minimum_established_time and (act_time := peer["establishedTime"]) < self.inputs.minimum_established_time:
+ msg = f"BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s"
+ self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - {msg}")
+
# Check if the AFI/SAFI state is negotiated
capability_status = get_value(peer, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}")
if not _check_bgp_neighbor_capability(capability_status):
@@ -221,7 +231,7 @@ class VerifyBGPPeersHealth(AntaTest):
inq = peer["peerTcpInfo"]["inputQueueLength"]
outq = peer["peerTcpInfo"]["outputQueueLength"]
if inq != 0 or outq != 0:
- self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Session has non-empty message queues - InQ: {inq}, OutQ: {outq}")
+ self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}")
class VerifyBGPSpecificPeers(AntaTest):
@@ -232,7 +242,7 @@ class VerifyBGPSpecificPeers(AntaTest):
1. Confirms that the specified VRF is configured.
2. For each specified peer:
- Verifies that the peer is found in the BGP configuration.
- - Checks that the BGP session is in the `Established` state.
+ - Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`.
- Confirms that the AFI/SAFI state is `negotiated`.
- Ensures that both input and output TCP message queues are empty.
Can be disabled by setting `check_tcp_queues` to `False`.
@@ -243,7 +253,8 @@ class VerifyBGPSpecificPeers(AntaTest):
* Failure: If any of the following occur:
- The specified VRF is not configured.
- A specified peer is not found in the BGP configuration.
- - The BGP session for a peer is not in the `Established` state.
+ - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by
+ the `minimum_established_time`.
- The AFI/SAFI state is not `negotiated` for a peer.
- Any TCP message queue (input or output) is not empty for a peer when `check_tcp_queues` is `True` (default).
@@ -253,6 +264,7 @@ class VerifyBGPSpecificPeers(AntaTest):
anta.tests.routing:
bgp:
- VerifyBGPSpecificPeers:
+ minimum_established_time: 10000
address_families:
- afi: "evpn"
peers:
@@ -274,6 +286,8 @@ class VerifyBGPSpecificPeers(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyBGPSpecificPeers test."""
+ minimum_established_time: PositiveInt | None = None
+ """Minimum established time (seconds) for all the BGP sessions."""
address_families: list[BgpAddressFamily]
"""List of BGP address families."""
BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi
@@ -311,9 +325,13 @@ class VerifyBGPSpecificPeers(AntaTest):
# Check if the BGP session is established
if peer_data["state"] != "Established":
- self.result.is_failure(f"{address_family} Peer: {peer_ip} - Session state is not established - State: {peer_data['state']}")
+ self.result.is_failure(f"{address_family} Peer: {peer_ip} - Incorrect session state - Expected: Established Actual: {peer_data['state']}")
continue
+ if self.inputs.minimum_established_time and (act_time := peer_data["establishedTime"]) < self.inputs.minimum_established_time:
+ msg = f"BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s"
+ self.result.is_failure(f"{address_family} Peer: {peer_ip} - {msg}")
+
# Check if the AFI/SAFI state is negotiated
capability_status = get_value(peer_data, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}")
if not capability_status:
@@ -323,15 +341,104 @@ class VerifyBGPSpecificPeers(AntaTest):
self.result.is_failure(f"{address_family} Peer: {peer_ip} - AFI/SAFI state is not negotiated - {format_data(capability_status)}")
# Check the TCP session message queues
- if address_family.check_tcp_queues:
- inq = peer_data["peerTcpInfo"]["inputQueueLength"]
- outq = peer_data["peerTcpInfo"]["outputQueueLength"]
- if inq != 0 or outq != 0:
- self.result.is_failure(f"{address_family} Peer: {peer_ip} - Session has non-empty message queues - InQ: {inq}, OutQ: {outq}")
+ inq = peer_data["peerTcpInfo"]["inputQueueLength"]
+ outq = peer_data["peerTcpInfo"]["outputQueueLength"]
+ if address_family.check_tcp_queues and (inq != 0 or outq != 0):
+ self.result.is_failure(f"{address_family} Peer: {peer_ip} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}")
+
+
+class VerifyBGPPeerSession(AntaTest):
+ """Verifies the session state of BGP IPv4 peer(s).
+
+ This test performs the following checks for each specified peer:
+
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`.
+ 3. Ensures that both input and output TCP message queues are empty.
+ Can be disabled by setting `check_tcp_queues` global flag to `False`.
+
+ Expected Results
+ ----------------
+ * Success: If all of the following conditions are met:
+ - All specified peers are found in the BGP configuration.
+ - All peers sessions state are `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`.
+ - All peers have empty TCP message queues if `check_tcp_queues` is `True` (default).
+ - All peers are established for specified minimum duration.
+ * Failure: If any of the following occur:
+ - A specified peer is not found in the BGP configuration.
+ - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by
+ the `minimum_established_time`.
+ - A peer has non-empty TCP message queues (input or output) when `check_tcp_queues` is `True`.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPPeerSession:
+ minimum_established_time: 10000
+ check_tcp_queues: false
+ bgp_peers:
+ - peer_address: 10.1.0.1
+ vrf: default
+ - peer_address: 10.1.0.2
+ vrf: default
+ - peer_address: 10.1.255.2
+ vrf: DEV
+ - peer_address: 10.1.255.4
+ vrf: DEV
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPPeerSession test."""
+
+ minimum_established_time: PositiveInt | None = None
+ """Minimum established time (seconds) for all the BGP sessions."""
+ check_tcp_queues: bool = True
+ """Flag to check if the TCP session queues are empty for all BGP peers. Defaults to `True`."""
+ bgp_peers: list[BgpPeer]
+ """List of BGP IPv4 peers."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPPeerSession."""
+ self.result.is_success()
+
+ output = self.instance_commands[0].json_output
+
+ for peer in self.inputs.bgp_peers:
+ peer_ip = str(peer.peer_address)
+ peer_list = get_value(output, f"vrfs.{peer.vrf}.peerList", default=[])
+
+ # Check if the peer is found
+ if (peer_data := get_item(peer_list, "peerAddress", peer_ip)) is None:
+ self.result.is_failure(f"{peer} - Not found")
+ continue
+
+ # Check if the BGP session is established
+ if peer_data["state"] != "Established":
+ self.result.is_failure(f"{peer} - Incorrect session state - Expected: Established Actual: {peer_data['state']}")
+ continue
+
+ if self.inputs.minimum_established_time and (act_time := peer_data["establishedTime"]) < self.inputs.minimum_established_time:
+ self.result.is_failure(
+ f"{peer} - BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s"
+ )
+
+ # Check the TCP session message queues
+ if self.inputs.check_tcp_queues:
+ inq = peer_data["peerTcpInfo"]["inputQueueLength"]
+ outq = peer_data["peerTcpInfo"]["outputQueueLength"]
+ if inq != 0 or outq != 0:
+ self.result.is_failure(f"{peer} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}")
class VerifyBGPExchangedRoutes(AntaTest):
- """Verifies the advertised and received routes of BGP peers.
+ """Verifies the advertised and received routes of BGP IPv4 peer(s).
This test performs the following checks for each specified peer:
@@ -366,8 +473,6 @@ class VerifyBGPExchangedRoutes(AntaTest):
advertised_routes:
- 192.0.255.1/32
- 192.0.254.5/32
- received_routes:
- - 192.0.254.3/32
```
"""
@@ -381,15 +486,15 @@ class VerifyBGPExchangedRoutes(AntaTest):
"""Input model for the VerifyBGPExchangedRoutes test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers."""
+ """List of BGP IPv4 peers."""
BgpNeighbor: ClassVar[type[BgpNeighbor]] = BgpNeighbor
@field_validator("bgp_peers")
@classmethod
def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]:
- """Validate that 'advertised_routes' or 'received_routes' field is provided in each address family."""
+ """Validate that 'advertised_routes' or 'received_routes' field is provided in each BGP peer."""
for peer in bgp_peers:
- if peer.advertised_routes is None or peer.received_routes is None:
+ if peer.advertised_routes is None and peer.received_routes is None:
msg = f"{peer} 'advertised_routes' or 'received_routes' field missing in the input"
raise ValueError(msg)
return bgp_peers
@@ -398,6 +503,20 @@ class VerifyBGPExchangedRoutes(AntaTest):
"""Render the template for each BGP peer in the input list."""
return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers]
+ def _validate_bgp_route_paths(self, peer: str, route_type: str, route: str, entries: dict[str, Any]) -> str | None:
+ """Validate the BGP route paths."""
+ # Check if the route is found
+ if route in entries:
+ # Check if the route is active and valid
+ route_paths = entries[route]["bgpRoutePaths"][0]["routeType"]
+ is_active = route_paths["active"]
+ is_valid = route_paths["valid"]
+ if not is_active or not is_valid:
+ return f"{peer} {route_type} route: {route} - Valid: {is_valid} Active: {is_active}"
+ return None
+
+ return f"{peer} {route_type} route: {route} - Not found"
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBGPExchangedRoutes."""
@@ -419,44 +538,38 @@ class VerifyBGPExchangedRoutes(AntaTest):
# Validate both advertised and received routes
for route_type, routes in zip(["Advertised", "Received"], [peer.advertised_routes, peer.received_routes]):
+ # Skipping the validation for routes if user input is None
+ if not routes:
+ continue
+
entries = command_output[route_type]
for route in routes:
- # Check if the route is found
- if str(route) not in entries:
- self.result.is_failure(f"{peer} {route_type} route: {route} - Not found")
- continue
-
- # Check if the route is active and valid
- route_paths = entries[str(route)]["bgpRoutePaths"][0]["routeType"]
- is_active = route_paths["active"]
- is_valid = route_paths["valid"]
- if not is_active or not is_valid:
- self.result.is_failure(f"{peer} {route_type} route: {route} - Valid: {is_valid}, Active: {is_active}")
+ # Check if the route is found. If yes then checks the route is active and valid
+ failure_msg = self._validate_bgp_route_paths(str(peer), route_type, str(route), entries)
+ if failure_msg:
+ self.result.is_failure(failure_msg)
class VerifyBGPPeerMPCaps(AntaTest):
- """Verifies the multiprotocol capabilities of BGP peers.
+ """Verifies the multiprotocol capabilities of BGP IPv4 peer(s).
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. For each specified capability:
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. For each specified capability:
- Validates that the capability is present in the peer configuration.
- Confirms that the capability is advertised, received, and enabled.
- 4. When strict mode is enabled (`strict: true`):
+ 3. When strict mode is enabled (`strict: true`):
- Verifies that only the specified capabilities are configured.
- Ensures an exact match between configured and expected capabilities.
Expected Results
----------------
* Success: If all of the following conditions are met:
- - The specified VRF is configured.
- All specified peers are found in the BGP configuration.
- All specified capabilities are present and properly negotiated.
- In strict mode, only the specified capabilities are configured.
* Failure: If any of the following occur:
- - The specified VRF is not configured.
- A specified peer is not found in the BGP configuration.
- A specified capability is not found.
- A capability is not properly negotiated (not advertised, received, or enabled).
@@ -473,7 +586,8 @@ class VerifyBGPPeerMPCaps(AntaTest):
vrf: default
strict: False
capabilities:
- - ipv4Unicast
+ - ipv4 labeled-Unicast
+ - ipv4MplsVpn
```
"""
@@ -484,13 +598,13 @@ class VerifyBGPPeerMPCaps(AntaTest):
"""Input model for the VerifyBGPPeerMPCaps test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@field_validator("bgp_peers")
@classmethod
def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]:
- """Validate that 'capabilities' field is provided in each address family."""
+ """Validate that 'capabilities' field is provided in each BGP peer."""
for peer in bgp_peers:
if peer.capabilities is None:
msg = f"{peer} 'capabilities' field missing in the input"
@@ -506,14 +620,10 @@ class VerifyBGPPeerMPCaps(AntaTest):
for peer in self.inputs.bgp_peers:
peer_ip = str(peer.peer_address)
-
- # Check if the VRF is configured
- if (vrf_output := get_value(output, f"vrfs.{peer.vrf}")) is None:
- self.result.is_failure(f"{peer} - VRF not configured")
- continue
+ peer_list = get_value(output, f"vrfs.{peer.vrf}.peerList", default=[])
# Check if the peer is found
- if (peer_data := get_item(vrf_output["peerList"], "peerAddress", peer_ip)) is None:
+ if (peer_data := get_item(peer_list, "peerAddress", peer_ip)) is None:
self.result.is_failure(f"{peer} - Not found")
continue
@@ -537,14 +647,13 @@ class VerifyBGPPeerMPCaps(AntaTest):
class VerifyBGPPeerASNCap(AntaTest):
- """Verifies the four octet ASN capability of BGP peers.
+ """Verifies the four octet ASN capability of BGP IPv4 peer(s).
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates that the capability is present in the peer configuration.
- 4. Confirms that the capability is advertised, received, and enabled.
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates that the capability is present in the peer configuration.
+ 3. Confirms that the capability is advertised, received, and enabled.
Expected Results
----------------
@@ -576,7 +685,7 @@ class VerifyBGPPeerASNCap(AntaTest):
"""Input model for the VerifyBGPPeerASNCap test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers."""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@AntaTest.anta_test
@@ -606,14 +715,13 @@ class VerifyBGPPeerASNCap(AntaTest):
class VerifyBGPPeerRouteRefreshCap(AntaTest):
- """Verifies the route refresh capabilities of a BGP peer in a specified VRF.
+ """Verifies the route refresh capabilities of IPv4 BGP peer(s) in a specified VRF.
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates that the route refresh capability is present in the peer configuration.
- 4. Confirms that the capability is advertised, received, and enabled.
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates that the route refresh capability is present in the peer configuration.
+ 3. Confirms that the capability is advertised, received, and enabled.
Expected Results
----------------
@@ -645,7 +753,7 @@ class VerifyBGPPeerRouteRefreshCap(AntaTest):
"""Input model for the VerifyBGPPeerRouteRefreshCap test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@AntaTest.anta_test
@@ -675,14 +783,13 @@ class VerifyBGPPeerRouteRefreshCap(AntaTest):
class VerifyBGPPeerMD5Auth(AntaTest):
- """Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF.
+ """Verifies the MD5 authentication and state of IPv4 BGP peer(s) in a specified VRF.
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates that the BGP session is in `Established` state.
- 4. Confirms that MD5 authentication is enabled for the peer.
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates that the BGP session is in `Established` state.
+ 3. Confirms that MD5 authentication is enabled for the peer.
Expected Results
----------------
@@ -739,7 +846,7 @@ class VerifyBGPPeerMD5Auth(AntaTest):
state = peer_data.get("state")
md5_auth_enabled = peer_data.get("md5AuthEnabled")
if state != "Established":
- self.result.is_failure(f"{peer} - Session state is not established - State: {state}")
+ self.result.is_failure(f"{peer} - Incorrect session state - Expected: Established Actual: {state}")
if not md5_auth_enabled:
self.result.is_failure(f"{peer} - Session does not have MD5 authentication enabled")
@@ -814,13 +921,12 @@ class VerifyEVPNType2Route(AntaTest):
class VerifyBGPAdvCommunities(AntaTest):
- """Verifies that advertised communities are standard, extended and large for BGP peers.
+ """Verifies that advertised communities are standard, extended and large for BGP IPv4 peer(s).
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates that all required community types are advertised:
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates that all required community types are advertised:
- Standard communities
- Extended communities
- Large communities
@@ -855,7 +961,7 @@ class VerifyBGPAdvCommunities(AntaTest):
"""Input model for the VerifyBGPAdvCommunities test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers."""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@AntaTest.anta_test
@@ -880,13 +986,12 @@ class VerifyBGPAdvCommunities(AntaTest):
class VerifyBGPTimers(AntaTest):
- """Verifies the timers of BGP peers.
+ """Verifies the timers of BGP IPv4 peer(s).
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Confirms the BGP session hold time/keepalive timers match the expected value.
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Confirms the BGP session hold time/keepalive timers match the expected value.
Expected Results
----------------
@@ -922,13 +1027,13 @@ class VerifyBGPTimers(AntaTest):
"""Input model for the VerifyBGPTimers test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@field_validator("bgp_peers")
@classmethod
def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]:
- """Validate that 'hold_time' or 'keep_alive_time' field is provided in each address family."""
+ """Validate that 'hold_time' or 'keep_alive_time' field is provided in each BGP peer."""
for peer in bgp_peers:
if peer.hold_time is None or peer.keep_alive_time is None:
msg = f"{peer} 'hold_time' or 'keep_alive_time' field missing in the input"
@@ -953,9 +1058,9 @@ class VerifyBGPTimers(AntaTest):
# Check BGP peer timers
if peer_data["holdTime"] != peer.hold_time:
- self.result.is_failure(f"{peer} - Hold time mismatch - Expected: {peer.hold_time}, Actual: {peer_data['holdTime']}")
+ self.result.is_failure(f"{peer} - Hold time mismatch - Expected: {peer.hold_time} Actual: {peer_data['holdTime']}")
if peer_data["keepaliveTime"] != peer.keep_alive_time:
- self.result.is_failure(f"{peer} - Keepalive time mismatch - Expected: {peer.keep_alive_time}, Actual: {peer_data['keepaliveTime']}")
+ self.result.is_failure(f"{peer} - Keepalive time mismatch - Expected: {peer.keep_alive_time} Actual: {peer_data['keepaliveTime']}")
class VerifyBGPPeerDropStats(AntaTest):
@@ -963,9 +1068,8 @@ class VerifyBGPPeerDropStats(AntaTest):
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates the BGP drop statistics:
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates the BGP drop statistics:
- If specific drop statistics are provided, checks only those counters.
- If no specific drop statistics are provided, checks all available counters.
- Confirms that all checked counters have a value of zero.
@@ -1002,7 +1106,7 @@ class VerifyBGPPeerDropStats(AntaTest):
"""Input model for the VerifyBGPPeerDropStats test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@AntaTest.anta_test
@@ -1040,9 +1144,8 @@ class VerifyBGPPeerUpdateErrors(AntaTest):
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates the BGP update error counters:
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates the BGP update error counters:
- If specific update error counters are provided, checks only those counters.
- If no update error counters are provided, checks all available counters.
- Confirms that all checked counters have a value of zero.
@@ -1080,7 +1183,7 @@ class VerifyBGPPeerUpdateErrors(AntaTest):
"""Input model for the VerifyBGPPeerUpdateErrors test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@AntaTest.anta_test
@@ -1118,9 +1221,8 @@ class VerifyBgpRouteMaps(AntaTest):
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Validates the correct BGP route maps are applied in the correct direction (inbound or outbound).
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Validates the correct BGP route maps are applied in the correct direction (inbound or outbound).
Expected Results
----------------
@@ -1152,19 +1254,16 @@ class VerifyBgpRouteMaps(AntaTest):
"""Input model for the VerifyBgpRouteMaps test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@field_validator("bgp_peers")
@classmethod
def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]:
- """Validate that 'peers' field is provided in each address family.
-
- At least one of 'inbound' or 'outbound' route-map must be provided.
- """
+ """Validate that 'inbound_route_map' or 'outbound_route_map' field is provided in each BGP peer."""
for peer in bgp_peers:
if not (peer.inbound_route_map or peer.outbound_route_map):
- msg = f"{peer}; At least one of 'inbound_route_map' or 'outbound_route_map' must be provided."
+ msg = f"{peer} 'inbound_route_map' or 'outbound_route_map' field missing in the input"
raise ValueError(msg)
return bgp_peers
@@ -1188,30 +1287,29 @@ class VerifyBgpRouteMaps(AntaTest):
# Verify Inbound route-map
if inbound_route_map and (inbound_map := peer_data.get("routeMapInbound", "Not Configured")) != inbound_route_map:
- self.result.is_failure(f"{peer} - Inbound route-map mismatch - Expected: {inbound_route_map}, Actual: {inbound_map}")
+ self.result.is_failure(f"{peer} - Inbound route-map mismatch - Expected: {inbound_route_map} Actual: {inbound_map}")
# Verify Outbound route-map
if outbound_route_map and (outbound_map := peer_data.get("routeMapOutbound", "Not Configured")) != outbound_route_map:
- self.result.is_failure(f"{peer} - Outbound route-map mismatch - Expected: {outbound_route_map}, Actual: {outbound_map}")
+ self.result.is_failure(f"{peer} - Outbound route-map mismatch - Expected: {outbound_route_map} Actual: {outbound_map}")
class VerifyBGPPeerRouteLimit(AntaTest):
- """Verifies maximum routes and outbound route-maps of BGP IPv4 peer(s).
+ """Verifies maximum routes and warning limit for BGP IPv4 peer(s).
This test performs the following checks for each specified peer:
- 1. Confirms that the specified VRF is configured.
- 2. Verifies that the peer exists in the BGP configuration.
- 3. Confirms the Maximum routes and maximum routes warning limit, if provided match the expected value.
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Confirms the maximum routes and maximum routes warning limit, if provided, match the expected value.
Expected Results
----------------
* Success: If all of the following conditions are met:
- All specified peers are found in the BGP configuration.
- - The maximum routese/maximum routes warning limit match the expected value for a peer.
+ - The maximum routes/maximum routes warning limit match the expected value for a peer.
* Failure: If any of the following occur:
- A specified peer is not found in the BGP configuration.
- - The maximum routese/maximum routes warning limit do not match the expected value for a peer.
+ - The maximum routes/maximum routes warning limit do not match the expected value for a peer.
Examples
--------
@@ -1234,16 +1332,16 @@ class VerifyBGPPeerRouteLimit(AntaTest):
"""Input model for the VerifyBGPPeerRouteLimit test."""
bgp_peers: list[BgpPeer]
- """List of BGP peers"""
+ """List of BGP IPv4 peers."""
BgpPeer: ClassVar[type[BgpPeer]] = BgpPeer
@field_validator("bgp_peers")
@classmethod
def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]:
- """Validate that 'peers' field is provided in each address family."""
+ """Validate that 'maximum_routes' field is provided in each BGP peer."""
for peer in bgp_peers:
if peer.maximum_routes is None:
- msg = f"{peer}; 'maximum_routes' field missing in the input"
+ msg = f"{peer} 'maximum_routes' field missing in the input"
raise ValueError(msg)
return bgp_peers
@@ -1265,10 +1363,648 @@ class VerifyBGPPeerRouteLimit(AntaTest):
self.result.is_failure(f"{peer} - Not found")
continue
- # Verify maximum routes configured.
- if (actual_routes := peer_data.get("maxTotalRoutes", "Not Found")) != maximum_routes:
- self.result.is_failure(f"{peer} - Maximum routes mismatch - Expected: {maximum_routes}, Actual: {actual_routes}")
+ # Verify maximum routes
+ if (actual_maximum_routes := peer_data.get("maxTotalRoutes", "Not Found")) != maximum_routes:
+ self.result.is_failure(f"{peer} - Maximum routes mismatch - Expected: {maximum_routes} Actual: {actual_maximum_routes}")
- # Verify warning limit if given.
- if warning_limit and (actual_warning_limit := peer_data.get("totalRoutesWarnLimit", "Not Found")) != warning_limit:
- self.result.is_failure(f"{peer} - Maximum route warning limit mismatch - Expected: {warning_limit}, Actual: {actual_warning_limit}")
+ # Verify warning limit if provided. By default, EOS does not have a warning limit and `totalRoutesWarnLimit` is not present in the output.
+ if warning_limit is not None and (actual_warning_limit := peer_data.get("totalRoutesWarnLimit", 0)) != warning_limit:
+ self.result.is_failure(f"{peer} - Maximum routes warning limit mismatch - Expected: {warning_limit} Actual: {actual_warning_limit}")
+
+
+class VerifyBGPPeerGroup(AntaTest):
+ """Verifies BGP peer group of BGP IPv4 peer(s).
+
+ This test performs the following checks for each specified peer:
+
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Confirms the peer group is correctly assigned to the specified BGP peer.
+
+ Expected Results
+ ----------------
+ * Success: If all of the following conditions are met:
+ - All specified peers are found in the BGP configuration.
+ - The peer group is correctly assigned to the specified BGP peer.
+ * Failure: If any of the following occur:
+ - A specified peer is not found in the BGP configuration.
+ - The peer group is not correctly assigned to the specified BGP peer.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPPeerGroup:
+ bgp_peers:
+ - peer_address: 172.30.11.1
+ vrf: default
+ peer_group: IPv4-UNDERLAY-PEERS
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPPeerGroup test."""
+
+ bgp_peers: list[BgpPeer]
+ """List of BGP IPv4 peers."""
+
+ @field_validator("bgp_peers")
+ @classmethod
+ def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]:
+ """Validate that 'peer_group' field is provided in each BGP peer."""
+ for peer in bgp_peers:
+ if peer.peer_group is None:
+ msg = f"{peer} 'peer_group' field missing in the input"
+ raise ValueError(msg)
+ return bgp_peers
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPPeerGroup."""
+ self.result.is_success()
+
+ output = self.instance_commands[0].json_output
+
+ for peer in self.inputs.bgp_peers:
+ peer_ip = str(peer.peer_address)
+ peer_list = get_value(output, f"vrfs.{peer.vrf}.peerList", default=[])
+
+ # Check if the peer is found
+ if (peer_data := get_item(peer_list, "peerAddress", peer_ip)) is None:
+ self.result.is_failure(f"{peer} - Not found")
+ continue
+
+ if (actual_peer_group := peer_data.get("peerGroupName", "Not Found")) != peer.peer_group:
+ self.result.is_failure(f"{peer} - Incorrect peer group configured - Expected: {peer.peer_group} Actual: {actual_peer_group}")
+
+
+class VerifyBGPPeerSessionRibd(AntaTest):
+ """Verifies the session state of BGP IPv4 peer(s).
+
+ Compatible with EOS operating in `ribd` routing protocol model.
+
+ This test performs the following checks for each specified peer:
+
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Verifies that the BGP session is `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`.
+ 3. Ensures that both input and output TCP message queues are empty.
+ Can be disabled by setting `check_tcp_queues` global flag to `False`.
+
+ Expected Results
+ ----------------
+ * Success: If all of the following conditions are met:
+ - All specified peers are found in the BGP configuration.
+ - All peers sessions state are `Established` and, if specified, has remained established for at least the duration given by `minimum_established_time`.
+ - All peers have empty TCP message queues if `check_tcp_queues` is `True` (default).
+ - All peers are established for specified minimum duration.
+ * Failure: If any of the following occur:
+ - A specified peer is not found in the BGP configuration.
+ - A peer's session state is not `Established` or if specified, has not remained established for at least the duration specified by
+ the `minimum_established_time`.
+ - A peer has non-empty TCP message queues (input or output) when `check_tcp_queues` is `True`.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPPeerSessionRibd:
+ minimum_established_time: 10000
+ check_tcp_queues: false
+ bgp_peers:
+ - peer_address: 10.1.0.1
+ vrf: default
+ - peer_address: 10.1.0.2
+ vrf: default
+ - peer_address: 10.1.255.2
+ vrf: DEV
+ - peer_address: 10.1.255.4
+ vrf: DEV
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp neighbors vrf all", revision=2)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPPeerSessionRibd test."""
+
+ minimum_established_time: PositiveInt | None = None
+ """Minimum established time (seconds) for all the BGP sessions."""
+ check_tcp_queues: bool = True
+ """Flag to check if the TCP session queues are empty for all BGP peers. Defaults to `True`."""
+ bgp_peers: list[BgpPeer]
+ """List of BGP IPv4 peers."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPPeerSessionRibd."""
+ self.result.is_success()
+
+ output = self.instance_commands[0].json_output
+
+ for peer in self.inputs.bgp_peers:
+ peer_address = str(peer.peer_address)
+ peers = get_value(output, f"vrfs.{peer.vrf}.peerList", default=[])
+
+ # Check if the peer is found
+ if (peer_data := get_item(peers, "peerAddress", peer_address)) is None:
+ self.result.is_failure(f"{peer} - Not found")
+ continue
+
+ # Check if the BGP session is established
+ if peer_data["state"] != "Established":
+ self.result.is_failure(f"{peer} - Incorrect session state - Expected: Established Actual: {peer_data['state']}")
+ continue
+
+ if self.inputs.minimum_established_time and (act_time := peer_data["establishedTime"]) < self.inputs.minimum_established_time:
+ self.result.is_failure(
+ f"{peer} - BGP session not established for the minimum required duration - Expected: {self.inputs.minimum_established_time}s Actual: {act_time}s"
+ )
+
+ # Check the TCP session message queues
+ if self.inputs.check_tcp_queues:
+ inq_stat = peer_data["peerTcpInfo"]["inputQueueLength"]
+ outq_stat = peer_data["peerTcpInfo"]["outputQueueLength"]
+ if inq_stat != 0 or outq_stat != 0:
+ self.result.is_failure(f"{peer} - Session has non-empty message queues - InQ: {inq_stat} OutQ: {outq_stat}")
+
+
+class VerifyBGPPeersHealthRibd(AntaTest):
+ """Verifies the health of all the BGP IPv4 peer(s).
+
+ Compatible with EOS operating in `ribd` routing protocol model.
+
+ This test performs the following checks for all BGP IPv4 peers:
+
+ 1. Verifies that the BGP session is in the `Established` state.
+ 2. Checks that both input and output TCP message queues are empty.
+ Can be disabled by setting `check_tcp_queues` global flag to `False`.
+
+ Expected Results
+ ----------------
+ * Success: If all checks pass for all BGP IPv4 peers.
+ * Failure: If any of the following occur:
+ - Any BGP session is not in the `Established` state.
+ - Any TCP message queue (input or output) is not empty when `check_tcp_queues` is `True` (default).
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPPeersHealthRibd:
+ check_tcp_queues: True
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp neighbors vrf all", revision=2)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPPeersHealthRibd test."""
+
+ check_tcp_queues: bool = True
+ """Flag to check if the TCP session queues are empty for all BGP peers. Defaults to `True`."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPPeersHealthRibd."""
+ self.result.is_success()
+
+ output = self.instance_commands[0].json_output
+
+ for vrf, vrf_data in output["vrfs"].items():
+ peer_list = vrf_data.get("peerList", [])
+
+ for peer in peer_list:
+ # Check if the BGP session is established
+ if peer["state"] != "Established":
+ self.result.is_failure(f"Peer: {peer['peerAddress']} VRF: {vrf} - Incorrect session state - Expected: Established Actual: {peer['state']}")
+ continue
+
+ # Check the TCP session message queues
+ inq = peer["peerTcpInfo"]["inputQueueLength"]
+ outq = peer["peerTcpInfo"]["outputQueueLength"]
+ if self.inputs.check_tcp_queues and (inq != 0 or outq != 0):
+ self.result.is_failure(f"Peer: {peer['peerAddress']} VRF: {vrf} - Session has non-empty message queues - InQ: {inq} OutQ: {outq}")
+
+
+class VerifyBGPNlriAcceptance(AntaTest):
+ """Verifies that all received NLRI are accepted for all AFI/SAFI configured for BGP IPv4 peer(s).
+
+ This test performs the following checks for each specified peer:
+
+ 1. Verifies that the peer is found in its VRF in the BGP configuration.
+ 2. Verifies that all received NLRI were accepted by comparing `nlrisReceived` with `nlrisAccepted`.
+
+ Expected Results
+ ----------------
+ * Success: If `nlrisReceived` equals `nlrisAccepted`, indicating all NLRI were accepted.
+ * Failure: If any of the following occur:
+ - The specified VRF is not configured.
+ - `nlrisReceived` does not equal `nlrisAccepted`, indicating some NLRI were rejected or filtered.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPNlriAcceptance:
+ bgp_peers:
+ - peer_address: 10.100.0.128
+ vrf: default
+ capabilities:
+ - ipv4Unicast
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp summary vrf all", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPNlriAcceptance test."""
+
+ bgp_peers: list[BgpPeer]
+ """List of BGP IPv4 peers."""
+
+ @field_validator("bgp_peers")
+ @classmethod
+ def validate_bgp_peers(cls, bgp_peers: list[T]) -> list[T]:
+ """Validate that 'capabilities' field is provided in each BGP peer."""
+ for peer in bgp_peers:
+ if peer.capabilities is None:
+ msg = f"{peer} 'capabilities' field missing in the input"
+ raise ValueError(msg)
+ return bgp_peers
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPNlriAcceptance."""
+ self.result.is_success()
+
+ output = self.instance_commands[0].json_output
+
+ for peer in self.inputs.bgp_peers:
+ # Check if the peer is found
+ if not (peer_data := get_value(output, f"vrfs..{peer.vrf}..peers..{peer.peer_address}", separator="..")):
+ self.result.is_failure(f"{peer} - Not found")
+ continue
+
+ # Fetching the multiprotocol capabilities
+ for capability in peer.capabilities:
+ # Check if the capability is found
+ if (capability_status := get_value(peer_data, capability)) is None:
+ self.result.is_failure(f"{peer} - {capability} not found")
+ continue
+
+ if capability_status["afiSafiState"] != "negotiated":
+ self.result.is_failure(f"{peer} - {capability} not negotiated")
+
+ if (received := capability_status.get("nlrisReceived")) != (accepted := capability_status.get("nlrisAccepted")):
+ self.result.is_failure(f"{peer} AFI/SAFI: {capability} - Some NLRI were filtered or rejected - Accepted: {accepted} Received: {received}")
+
+
+class VerifyBGPRoutePaths(AntaTest):
+ """Verifies BGP IPv4 route paths.
+
+ This test performs the following checks for each specified BGP route entry:
+
+ 1. Verifies the specified BGP route exists in the routing table.
+ 2. For each expected paths:
+ - Verifies a path with matching next-hop exists.
+ - Verifies the path's origin attribute matches the expected value.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all specified routes exist with paths matching the expected next-hops and origin attributes.
+ * Failure: The test will fail if:
+ - A specified BGP route is not found.
+ - A path with specified next-hop is not found.
+ - A path's origin attribute doesn't match the expected value.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPRoutePaths:
+ route_entries:
+ - prefix: 10.100.0.128/31
+ vrf: default
+ paths:
+ - nexthop: 10.100.0.10
+ origin: Igp
+ - nexthop: 10.100.4.5
+ origin: Incomplete
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp vrf all", revision=3)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPRoutePaths test."""
+
+ route_entries: list[BgpRoute]
+ """List of BGP IPv4 route(s)."""
+
+ @field_validator("route_entries")
+ @classmethod
+ def validate_route_entries(cls, route_entries: list[BgpRoute]) -> list[BgpRoute]:
+ """Validate that 'paths' field is provided in each BGP route."""
+ for route in route_entries:
+ if route.paths is None:
+ msg = f"{route} 'paths' field missing in the input"
+ raise ValueError(msg)
+ return route_entries
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPRoutePaths."""
+ self.result.is_success()
+
+ for route in self.inputs.route_entries:
+ # Verify if the prefix exists in BGP table
+ if not (bgp_routes := get_value(self.instance_commands[0].json_output, f"vrfs..{route.vrf}..bgpRouteEntries..{route.prefix}", separator="..")):
+ self.result.is_failure(f"{route} - Prefix not found")
+ continue
+
+ # Iterating over each path.
+ for path in route.paths:
+ nexthop = str(path.nexthop)
+ origin = path.origin
+ if not (route_path := get_item(bgp_routes["bgpRoutePaths"], "nextHop", nexthop)):
+ self.result.is_failure(f"{route} {path} - Path not found")
+ continue
+
+ if (actual_origin := get_value(route_path, "routeType.origin")) != origin:
+ self.result.is_failure(f"{route} {path} - Origin mismatch - Actual: {actual_origin}")
+
+
+class VerifyBGPRouteECMP(AntaTest):
+ """Verifies BGP IPv4 route ECMP paths.
+
+ This test performs the following checks for each specified BGP route entry:
+
+ 1. Route exists in BGP table.
+ 2. First path is a valid and active ECMP head.
+ 3. Correct number of valid ECMP contributors follow the head path.
+ 4. Route is installed in RIB with same amount of next-hops.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all specified routes exist in both BGP and RIB tables with correct amount of ECMP paths.
+ * Failure: The test will fail if:
+ - A specified route is not found in BGP table.
+ - A valid and active ECMP head is not found.
+ - ECMP contributors count does not match the expected value.
+ - Route is not installed in RIB table.
+ - BGP and RIB nexthops count do not match.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPRouteECMP:
+ route_entries:
+ - prefix: 10.100.0.128/31
+ vrf: default
+ ecmp_count: 2
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
+ AntaCommand(command="show ip bgp vrf all", revision=3),
+ AntaCommand(command="show ip route vrf all bgp", revision=4),
+ ]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPRouteECMP test."""
+
+ route_entries: list[BgpRoute]
+ """List of BGP IPv4 route(s)."""
+
+ @field_validator("route_entries")
+ @classmethod
+ def validate_route_entries(cls, route_entries: list[BgpRoute]) -> list[BgpRoute]:
+ """Validate that 'ecmp_count' field is provided in each BGP route."""
+ for route in route_entries:
+ if route.ecmp_count is None:
+ msg = f"{route} 'ecmp_count' field missing in the input"
+ raise ValueError(msg)
+ return route_entries
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPRouteECMP."""
+ self.result.is_success()
+
+ for route in self.inputs.route_entries:
+ # Verify if the prefix exists in BGP table.
+ if not (bgp_route_entry := get_value(self.instance_commands[0].json_output, f"vrfs..{route.vrf}..bgpRouteEntries..{route.prefix}", separator="..")):
+ self.result.is_failure(f"{route} - Prefix not found in BGP table")
+ continue
+
+ route_paths = iter(bgp_route_entry["bgpRoutePaths"])
+ head = next(route_paths, None)
+ # Verify if the active ECMP head exists.
+ if head is None or not all(head["routeType"][key] for key in ["valid", "active", "ecmpHead"]):
+ self.result.is_failure(f"{route} - Valid and active ECMP head not found")
+ continue
+
+ bgp_nexthops = {head["nextHop"]}
+ bgp_nexthops.update([path["nextHop"] for path in route_paths if all(path["routeType"][key] for key in ["valid", "ecmp", "ecmpContributor"])])
+
+ # Verify ECMP count is correct.
+ if len(bgp_nexthops) != route.ecmp_count:
+ self.result.is_failure(f"{route} - ECMP count mismatch - Expected: {route.ecmp_count} Actual: {len(bgp_nexthops)}")
+ continue
+
+ # Verify if the prefix exists in routing table.
+ if not (route_entry := get_value(self.instance_commands[1].json_output, f"vrfs..{route.vrf}..routes..{route.prefix}", separator="..")):
+ self.result.is_failure(f"{route} - Prefix not found in routing table")
+ continue
+
+ # Verify BGP and RIB nexthops are same.
+ if len(bgp_nexthops) != len(route_entry["vias"]):
+ self.result.is_failure(f"{route} - Nexthops count mismatch - BGP: {len(bgp_nexthops)} RIB: {len(route_entry['vias'])}")
+
+
+class VerifyBGPRedistribution(AntaTest):
+ """Verifies BGP redistribution.
+
+ This test performs the following checks for each specified VRF in the BGP instance:
+
+ 1. Ensures that the expected address-family is configured on the device.
+ 2. Confirms that the redistributed route protocol, include leaked and route map match the expected values.
+
+
+ Expected Results
+ ----------------
+ * Success: If all of the following conditions are met:
+ - The expected address-family is configured on the device.
+ - The redistributed route protocol, include leaked and route map align with the expected values for the route.
+ * Failure: If any of the following occur:
+ - The expected address-family is not configured on device.
+ - The redistributed route protocol, include leaked or route map does not match the expected values.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPRedistribution:
+ vrfs:
+ - vrf: default
+ address_families:
+ - afi_safi: ipv4multicast
+ redistributed_routes:
+ - proto: Connected
+ include_leaked: True
+ route_map: RM-CONN-2-BGP
+ - proto: IS-IS
+ include_leaked: True
+ route_map: RM-CONN-2-BGP
+ - afi_safi: IPv6 Unicast
+ redistributed_routes:
+ - proto: User # Converted to EOS SDK
+ route_map: RM-CONN-2-BGP
+ - proto: Static
+ include_leaked: True
+ route_map: RM-CONN-2-BGP
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp instance vrf all", revision=4)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPRedistribution test."""
+
+ vrfs: list[BgpVrf]
+ """List of VRFs in the BGP instance."""
+
+ def _validate_redistribute_route(self, vrf_data: str, addr_family: str, afi_safi_configs: list[dict[str, Any]], route_info: dict[str, Any]) -> list[Any]:
+ """Validate the redstributed route details for a given address family."""
+ failure_msg = []
+ # If the redistributed route protocol does not match the expected value, test fails.
+ if not (actual_route := get_item(afi_safi_configs.get("redistributedRoutes"), "proto", route_info.proto)):
+ failure_msg.append(f"{vrf_data}, {addr_family}, Proto: {route_info.proto} - Not configured")
+ return failure_msg
+
+ # If includes leaked field applicable, and it does not matches the expected value, test fails.
+ if (act_include_leaked := actual_route.get("includeLeaked", False)) != route_info.include_leaked:
+ failure_msg.append(f"{vrf_data}, {addr_family}, {route_info} - Include leaked mismatch - Actual: {act_include_leaked}")
+
+ # If route map is required and it is not matching the expected value, test fails.
+ if all([route_info.route_map, (act_route_map := actual_route.get("routeMap", "Not Found")) != route_info.route_map]):
+ failure_msg.append(f"{vrf_data}, {addr_family}, {route_info} - Route map mismatch - Actual: {act_route_map}")
+ return failure_msg
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPRedistribution."""
+ self.result.is_success()
+ command_output = self.instance_commands[0].json_output
+
+ for vrf_data in self.inputs.vrfs:
+ # If the specified VRF details are not found, test fails.
+ if not (instance_details := get_value(command_output, f"vrfs.{vrf_data.vrf}")):
+ self.result.is_failure(f"{vrf_data} - Not configured")
+ continue
+ for address_family in vrf_data.address_families:
+ # If the AFI-SAFI configuration details are not found, test fails.
+ if not (afi_safi_configs := get_value(instance_details, f"afiSafiConfig.{address_family.afi_safi}")):
+ self.result.is_failure(f"{vrf_data}, {address_family} - Not redistributed")
+ continue
+
+ for route_info in address_family.redistributed_routes:
+ failure_msg = self._validate_redistribute_route(str(vrf_data), str(address_family), afi_safi_configs, route_info)
+ for msg in failure_msg:
+ self.result.is_failure(msg)
+
+
+class VerifyBGPPeerTtlMultiHops(AntaTest):
+ """Verifies BGP TTL and max-ttl-hops count for BGP IPv4 peer(s).
+
+ This test performs the following checks for each specified BGP peer:
+
+ 1. Verifies the specified BGP peer exists in the BGP configuration.
+ 2. Verifies the TTL and max-ttl-hops attribute matches the expected value.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all specified peers exist with TTL and max-ttl-hops attributes matching the expected values.
+ * Failure: If any of the following occur:
+ - A specified BGP peer is not found.
+ - A TTL or max-ttl-hops attribute doesn't match the expected value for any peer.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ bgp:
+ - VerifyBGPPeerTtlMultiHops:
+ bgp_peers:
+ - peer_address: 172.30.11.1
+ vrf: default
+ ttl: 3
+ max_ttl_hops: 3
+ - peer_address: 172.30.11.2
+ vrf: test
+ ttl: 30
+ max_ttl_hops: 30
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["bgp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip bgp neighbors vrf all", revision=2)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyBGPPeerTtlMultiHops test."""
+
+ bgp_peers: list[BgpPeer]
+ """List of IPv4 peer(s)."""
+
+ @field_validator("bgp_peers")
+ @classmethod
+ def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]:
+ """Validate that 'ttl' and 'max_ttl_hops' field is provided in each BGP peer."""
+ for peer in bgp_peers:
+ if peer.ttl is None:
+ msg = f"{peer} 'ttl' field missing in the input"
+ raise ValueError(msg)
+ if peer.max_ttl_hops is None:
+ msg = f"{peer} 'max_ttl_hops' field missing in the input"
+ raise ValueError(msg)
+
+ return bgp_peers
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyBGPPeerTtlMultiHops."""
+ self.result.is_success()
+ command_output = self.instance_commands[0].json_output
+
+ for peer in self.inputs.bgp_peers:
+ peer_ip = str(peer.peer_address)
+ peer_list = get_value(command_output, f"vrfs.{peer.vrf}.peerList", default=[])
+
+ # Check if the peer is found
+ if (peer_details := get_item(peer_list, "peerAddress", peer_ip)) is None:
+ self.result.is_failure(f"{peer} - Not found")
+ continue
+
+ # Verify if the TTL duration matches the expected value.
+ if peer_details.get("ttl") != peer.ttl:
+ self.result.is_failure(f"{peer} - TTL mismatch - Expected: {peer.ttl} Actual: {peer_details.get('ttl')}")
+
+ # Verify if the max-ttl-hops time matches the expected value.
+ if peer_details.get("maxTtlHops") != peer.max_ttl_hops:
+ self.result.is_failure(f"{peer} - Max TTL Hops mismatch - Expected: {peer.max_ttl_hops} Actual: {peer_details.get('maxTtlHops')}")
diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py
index 7b916a3..066d39c 100644
--- a/anta/tests/routing/generic.py
+++ b/anta/tests/routing/generic.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 generic routing tests."""
@@ -11,12 +11,12 @@ from functools import cache
from ipaddress import IPv4Address, IPv4Interface
from typing import TYPE_CHECKING, ClassVar, Literal
-from pydantic import model_validator
+from pydantic import field_validator, model_validator
from anta.custom_types import PositiveInteger
from anta.input_models.routing.generic import IPv4Routes
from anta.models import AntaCommand, AntaTemplate, AntaTest
-from anta.tools import get_value
+from anta.tools import get_item, get_value
if TYPE_CHECKING:
import sys
@@ -63,7 +63,7 @@ class VerifyRoutingProtocolModel(AntaTest):
if configured_model == operating_model == self.inputs.model:
self.result.is_success()
else:
- self.result.is_failure(f"routing model is misconfigured: configured: {configured_model} - operating: {operating_model} - expected: {self.inputs.model}")
+ self.result.is_failure(f"Routing model is misconfigured - Expected: {self.inputs.model} Actual: {operating_model}")
class VerifyRoutingTableSize(AntaTest):
@@ -112,7 +112,9 @@ class VerifyRoutingTableSize(AntaTest):
if self.inputs.minimum <= total_routes <= self.inputs.maximum:
self.result.is_success()
else:
- self.result.is_failure(f"routing-table has {total_routes} routes and not between min ({self.inputs.minimum}) and maximum ({self.inputs.maximum})")
+ self.result.is_failure(
+ f"Routing table routes are outside the routes range - Expected: {self.inputs.minimum} <= to >= {self.inputs.maximum} Actual: {total_routes}"
+ )
class VerifyRoutingTableEntry(AntaTest):
@@ -182,16 +184,17 @@ class VerifyRoutingTableEntry(AntaTest):
if not missing_routes:
self.result.is_success()
else:
- self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {missing_routes}")
+ self.result.is_failure(f"The following route(s) are missing from the routing table of VRF {self.inputs.vrf}: {', '.join(missing_routes)}")
class VerifyIPv4RouteType(AntaTest):
"""Verifies the route-type of the IPv4 prefixes.
This test performs the following checks for each IPv4 route:
- 1. Verifies that the specified VRF is configured.
- 2. Verifies that the specified IPv4 route is exists in the configuration.
- 3. Verifies that the the specified IPv4 route is of the expected type.
+
+ 1. Verifies that the specified VRF is configured.
+ 2. Verifies that the specified IPv4 route is exists in the configuration.
+ 3. Verifies that the the specified IPv4 route is of the expected type.
Expected Results
----------------
@@ -230,6 +233,17 @@ class VerifyIPv4RouteType(AntaTest):
"""Input model for the VerifyIPv4RouteType test."""
routes_entries: list[IPv4Routes]
+ """List of IPv4 route(s)."""
+
+ @field_validator("routes_entries")
+ @classmethod
+ def validate_routes_entries(cls, routes_entries: list[IPv4Routes]) -> list[IPv4Routes]:
+ """Validate that 'route_type' field is provided in each BGP route entry."""
+ for entry in routes_entries:
+ if entry.route_type is None:
+ msg = f"{entry} 'route_type' field missing in the input"
+ raise ValueError(msg)
+ return routes_entries
@AntaTest.anta_test
def test(self) -> None:
@@ -256,3 +270,82 @@ class VerifyIPv4RouteType(AntaTest):
# Verifying that the specified IPv4 routes are of the expected type.
if expected_route_type != (actual_route_type := route_data.get("routeType")):
self.result.is_failure(f"{entry} - Incorrect route type - Expected: {expected_route_type} Actual: {actual_route_type}")
+
+
+class VerifyIPv4RouteNextHops(AntaTest):
+ """Verifies the next-hops of the IPv4 prefixes.
+
+ This test performs the following checks for each IPv4 prefix:
+
+ 1. Verifies the specified IPv4 route exists in the routing table.
+ 2. For each specified next-hop:
+ - Verifies a path with matching next-hop exists.
+ - Supports `strict: True` to verify that routes must be learned exclusively via the exact next-hops specified.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if routes exist with paths matching the expected next-hops.
+ * Failure: The test will fail if:
+ - A route entry is not found for given IPv4 prefixes.
+ - A path with specified next-hop is not found.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.routing:
+ generic:
+ - VerifyIPv4RouteNextHops:
+ route_entries:
+ - prefix: 10.10.0.1/32
+ vrf: default
+ strict: false
+ nexthops:
+ - 10.100.0.8
+ - 10.100.0.10
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["routing"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route vrf all", revision=4)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyIPv4RouteNextHops test."""
+
+ route_entries: list[IPv4Routes]
+ """List of IPv4 route(s)."""
+
+ @field_validator("route_entries")
+ @classmethod
+ def validate_route_entries(cls, route_entries: list[IPv4Routes]) -> list[IPv4Routes]:
+ """Validate that 'nexthops' field is provided in each route entry."""
+ for entry in route_entries:
+ if entry.nexthops is None:
+ msg = f"{entry} 'nexthops' field missing in the input"
+ raise ValueError(msg)
+ return route_entries
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyIPv4RouteNextHops."""
+ self.result.is_success()
+
+ output = self.instance_commands[0].json_output
+
+ for entry in self.inputs.route_entries:
+ # Verify if the prefix exists in route table
+ if (route_data := get_value(output, f"vrfs..{entry.vrf}..routes..{entry.prefix}", separator="..")) is None:
+ self.result.is_failure(f"{entry} - prefix not found")
+ continue
+
+ # Verify the nexthop addresses
+ actual_nexthops = sorted(["Directly connected" if (next_hop := route.get("nexthopAddr")) == "" else next_hop for route in route_data["vias"]])
+ expected_nexthops = sorted([str(nexthop) for nexthop in entry.nexthops])
+
+ if entry.strict and expected_nexthops != actual_nexthops:
+ exp_nexthops = ", ".join(expected_nexthops)
+ self.result.is_failure(f"{entry} - List of next-hops not matching - Expected: {exp_nexthops} Actual: {', '.join(actual_nexthops)}")
+ continue
+
+ for nexthop in entry.nexthops:
+ if not get_item(route_data["vias"], "nexthopAddr", str(nexthop)):
+ self.result.is_failure(f"{entry} Nexthop: {nexthop} - Route not found")
diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py
index 54a4f14..6a73e40 100644
--- a/anta/tests/routing/isis.py
+++ b/anta/tests/routing/isis.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -7,147 +7,23 @@
# mypy: disable-error-code=attr-defined
from __future__ import annotations
-from ipaddress import IPv4Address, IPv4Network
-from typing import Any, ClassVar, Literal
+from typing import Any, ClassVar
-from pydantic import BaseModel
+from pydantic import field_validator
-from anta.custom_types import Interface
+from anta.input_models.routing.isis import Entry, InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface, Tunnel, TunnelPath
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.
-
- Parameters
- ----------
- 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`.
-
- Parameters
- ----------
- isis_neighbor_json
- The JSON output of the `show isis neighbors` command.
-
- Returns
- -------
- list[dict[str, Any]]
- A list of isis neighbors whose adjacency state is not `UP`.
-
- """
- 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`.
-
- Parameters
- ----------
- isis_neighbor_json
- The JSON output of the `show isis neighbors` command.
- neighbor_state
- Value of the neihbor state we are looking for. Defaults to `up`.
-
- Returns
- -------
- list[dict[str, Any]]
- A list of isis neighbors whose adjacency state is not `UP`.
-
- """
- 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
-
-
-def _get_adjacency_segment_data_by_neighbor(neighbor: str, instance: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None:
- """Extract data related to an IS-IS interface for testing."""
- search_path = f"vrfs.{vrf}.isisInstances.{instance}.adjacencySegments"
- if get_value(dictionary=command_output, key=search_path, default=None) is None:
- return None
-
- isis_instance = get_value(dictionary=command_output, key=search_path, default=None)
-
- return next(
- (segment_data for segment_data in isis_instance if neighbor == segment_data["ipAddress"]),
- None,
- )
+from anta.tools import get_item, get_value
class VerifyISISNeighborState(AntaTest):
- """Verifies all IS-IS neighbors are in UP state.
+ """Verifies the health of IS-IS neighbors.
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.
+ * Success: The test will pass if all IS-IS neighbors are in the `up` state.
+ * Failure: The test will fail if any IS-IS neighbor adjacency is down.
+ * Skipped: The test will be skipped if IS-IS is not configured or no IS-IS neighbor is found.
Examples
--------
@@ -155,33 +31,58 @@ class VerifyISISNeighborState(AntaTest):
anta.tests.routing:
isis:
- VerifyISISNeighborState:
+ check_all_vrfs: true
```
"""
categories: ClassVar[list[str]] = ["isis"]
- commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors vrf all", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyISISNeighborState test."""
+
+ check_all_vrfs: bool = False
+ """If enabled, verifies IS-IS instances of all VRFs."""
@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}.")
+
+ # Verify if IS-IS is configured
+ if not (command_output := self.instance_commands[0].json_output["vrfs"]):
+ self.result.is_skipped("IS-IS not configured")
+ return
+
+ vrfs_to_check = command_output
+ if not self.inputs.check_all_vrfs:
+ vrfs_to_check = {"default": command_output["default"]}
+
+ no_neighbor = True
+ for vrf, vrf_data in vrfs_to_check.items():
+ for isis_instance, instance_data in vrf_data["isisInstances"].items():
+ neighbors = instance_data["neighbors"]
+ if not neighbors:
+ continue
+ no_neighbor = False
+ interfaces = [(adj["interfaceName"], adj["state"]) for neighbor in neighbors.values() for adj in neighbor["adjacencies"] if adj["state"] != "up"]
+ for interface in interfaces:
+ self.result.is_failure(
+ f"Instance: {isis_instance} VRF: {vrf} Interface: {interface[0]} - Incorrect adjacency state - Expected: up Actual: {interface[1]}"
+ )
+
+ if no_neighbor:
+ self.result.is_skipped("No IS-IS neighbor detected")
class VerifyISISNeighborCount(AntaTest):
- """Verifies number of IS-IS neighbors per level and per interface.
+ """Verifies the number of IS-IS neighbors per interface and level.
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.
+ * Success: The test will pass if all provided IS-IS interfaces have the expected number of neighbors.
+ * Failure: The test will fail if any of the provided IS-IS interfaces are not configured or have an incorrect number of neighbors.
+ * Skipped: The test will be skipped if IS-IS is not configured.
Examples
--------
@@ -198,59 +99,54 @@ class VerifyISISNeighborCount(AntaTest):
count: 1
- name: Ethernet3
count: 2
- # level is set to 2 by default
```
"""
categories: ClassVar[list[str]] = ["isis"]
- commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief vrf all", 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."""
+ interfaces: list[ISISInterface]
+ """List of IS-IS interfaces with their information."""
+ InterfaceCount: ClassVar[type[InterfaceCount]] = InterfaceCount
@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")
+
+ # Verify if IS-IS is configured
+ if not (command_output := self.instance_commands[0].json_output["vrfs"]):
+ self.result.is_skipped("IS-IS not configured")
return
+
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}")
+ interface_detail = {}
+ vrf_instances = get_value(command_output, f"{interface.vrf}..isisInstances", default={}, separator="..")
+ for instance_data in vrf_instances.values():
+ if interface_data := get_value(instance_data, f"interfaces..{interface.name}..intfLevels..{interface.level}", separator=".."):
+ interface_detail = interface_data
+ # An interface can only be configured in one IS-IS instance at a time
+ break
+
+ if not interface_detail:
+ self.result.is_failure(f"{interface} - Not configured")
continue
- 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']}"
- )
+
+ if interface_detail["passive"] is False and (act_count := interface_detail["numAdjacencies"]) != interface.count:
+ self.result.is_failure(f"{interface} - Neighbor count mismatch - Expected: {interface.count} Actual: {act_count}")
class VerifyISISInterfaceMode(AntaTest):
- """Verifies ISIS Interfaces are running in correct mode.
+ """Verifies IS-IS interfaces are running in the 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.
+ * Success: The test will pass if all provided IS-IS interfaces are running in the correct mode.
+ * Failure: The test will fail if any of the provided IS-IS interfaces are not configured or running in the incorrect mode.
+ * Skipped: The test will be skipped if IS-IS is not configured.
Examples
--------
@@ -261,80 +157,71 @@ class VerifyISISInterfaceMode(AntaTest):
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
+ vrf: PROD
```
"""
- 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)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief vrf all", revision=1)]
class Input(AntaTest.Input):
- """Input model for the VerifyISISNeighborCount test."""
+ """Input model for the VerifyISISInterfaceMode 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"""
+ interfaces: list[ISISInterface]
+ """List of IS-IS interfaces with their information."""
+ InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState
@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_skipped("IS-IS is not configured on device")
+ # Verify if IS-IS is configured
+ if not (command_output := self.instance_commands[0].json_output["vrfs"]):
+ self.result.is_skipped("IS-IS not configured")
return
- # 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}")
+ interface_detail = {}
+ vrf_instances = get_value(command_output, f"{interface.vrf}..isisInstances", default={}, separator="..")
+ for instance_data in vrf_instances.values():
+ if interface_data := get_value(instance_data, f"interfaces..{interface.name}", separator=".."):
+ interface_detail = interface_data
+ # An interface can only be configured in one IS-IS instance at a time
+ break
+
+ if not interface_detail:
+ self.result.is_failure(f"{interface} - Not configured")
+ continue
+
+ # Check for passive
+ if interface.mode == "passive":
+ if get_value(interface_detail, f"intfLevels.{interface.level}.passive", default=False) is False:
+ self.result.is_failure(f"{interface} - Not running in passive mode")
+
+ # Check for point-to-point or broadcast
+ elif interface.mode != (interface_type := get_value(interface_detail, "interfaceType", default="unset")):
+ self.result.is_failure(f"{interface} - Incorrect interface mode - Expected: {interface.mode} Actual: {interface_type}")
class VerifyISISSegmentRoutingAdjacencySegments(AntaTest):
- """Verify that all expected Adjacency segments are correctly visible for each interface.
+ """Verifies IS-IS segment routing adjacency segments.
+
+ !!! warning "IS-IS SR Limitation"
+ As of EOS 4.33.1F, IS-IS SR is supported only in the default VRF.
+ Please refer to the IS-IS Segment Routing [documentation](https://www.arista.com/en/support/toi/eos-4-17-0f/13789-isis-segment-routing)
+ for more information.
Expected Results
----------------
- * Success: The test will pass if all listed interfaces have correct adjacencies.
- * Failure: The test will fail if any of the listed interfaces has not expected list of adjacencies.
- * Skipped: The test will be skipped if no ISIS SR Adjacency is found.
+ * Success: The test will pass if all provided IS-IS instances have the correct adjacency segments.
+ * Failure: The test will fail if any of the provided IS-IS instances have no adjacency segments or incorrect segments.
+ * Skipped: The test will be skipped if IS-IS is not configured.
Examples
--------
@@ -358,91 +245,62 @@ class VerifyISISSegmentRoutingAdjacencySegments(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyISISSegmentRoutingAdjacencySegments test."""
- instances: list[IsisInstance]
+ instances: list[ISISInstance]
+ """List of IS-IS instances with their information."""
+ IsisInstance: ClassVar[type[IsisInstance]] = IsisInstance
- class IsisInstance(BaseModel):
- """ISIS Instance model definition."""
-
- name: str
- """ISIS instance name."""
- vrf: str = "default"
- """VRF name where ISIS instance is configured."""
- segments: list[Segment]
- """List of Adjacency segments configured in this instance."""
-
- class Segment(BaseModel):
- """Segment model definition."""
-
- interface: Interface
- """Interface name to check."""
- level: Literal[1, 2] = 2
- """ISIS level configured for interface. Default is 2."""
- sid_origin: Literal["dynamic"] = "dynamic"
- """Adjacency type"""
- address: IPv4Address
- """IP address of remote end of segment."""
+ @field_validator("instances")
+ @classmethod
+ def validate_instances(cls, instances: list[ISISInstance]) -> list[ISISInstance]:
+ """Validate that 'vrf' field is 'default' in each IS-IS instance."""
+ for instance in instances:
+ if instance.vrf != "default":
+ msg = f"{instance} 'vrf' field must be 'default'"
+ raise ValueError(msg)
+ return instances
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISSegmentRoutingAdjacencySegments."""
- command_output = self.instance_commands[0].json_output
self.result.is_success()
- if len(command_output["vrfs"]) == 0:
- self.result.is_skipped("IS-IS is not configured on device")
+ # Verify if IS-IS is configured
+ if not (command_output := self.instance_commands[0].json_output["vrfs"]):
+ self.result.is_skipped("IS-IS not configured")
return
- # initiate defaults
- failure_message = []
- skip_vrfs = []
- skip_instances = []
-
- # Check if VRFs and instances are present in output.
for instance in self.inputs.instances:
- vrf_data = get_value(
- dictionary=command_output,
- key=f"vrfs.{instance.vrf}",
- default=None,
- )
- if vrf_data is None:
- skip_vrfs.append(instance.vrf)
- failure_message.append(f"VRF {instance.vrf} is not configured to run segment routging.")
+ if not (act_segments := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}..adjacencySegments", default=[], separator="..")):
+ self.result.is_failure(f"{instance} - No adjacency segments found")
+ continue
- elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None:
- skip_instances.append(instance.name)
- failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.")
+ for segment in instance.segments:
+ if (act_segment := get_item(act_segments, "ipAddress", str(segment.address))) is None:
+ self.result.is_failure(f"{instance} {segment} - Adjacency segment not found")
+ continue
- # Check Adjacency segments
- for instance in self.inputs.instances:
- if instance.vrf not in skip_vrfs and instance.name not in skip_instances:
- for input_segment in instance.segments:
- eos_segment = _get_adjacency_segment_data_by_neighbor(
- neighbor=str(input_segment.address),
- instance=instance.name,
- vrf=instance.vrf,
- command_output=command_output,
- )
- if eos_segment is None:
- failure_message.append(f"Your segment has not been found: {input_segment}.")
+ # Check SID origin
+ if (act_origin := act_segment["sidOrigin"]) != segment.sid_origin:
+ self.result.is_failure(f"{instance} {segment} - Incorrect SID origin - Expected: {segment.sid_origin} Actual: {act_origin}")
- elif (
- eos_segment["localIntf"] != input_segment.interface
- or eos_segment["level"] != input_segment.level
- or eos_segment["sidOrigin"] != input_segment.sid_origin
- ):
- failure_message.append(f"Your segment is not correct: Expected: {input_segment} - Found: {eos_segment}.")
- if failure_message:
- self.result.is_failure("\n".join(failure_message))
+ # Check IS-IS level
+ if (actual_level := act_segment["level"]) != segment.level:
+ self.result.is_failure(f"{instance} {segment} - Incorrect IS-IS level - Expected: {segment.level} Actual: {actual_level}")
class VerifyISISSegmentRoutingDataplane(AntaTest):
- """Verify dataplane of a list of ISIS-SR instances.
+ """Verifies IS-IS segment routing data-plane configuration.
+
+ !!! warning "IS-IS SR Limitation"
+ As of EOS 4.33.1F, IS-IS SR is supported only in the default VRF.
+ Please refer to the IS-IS Segment Routing [documentation](https://www.arista.com/en/support/toi/eos-4-17-0f/13789-isis-segment-routing)
+ for more information.
Expected Results
----------------
- * Success: The test will pass if all instances have correct dataplane configured
- * Failure: The test will fail if one of the instances has incorrect dataplane configured
- * Skipped: The test will be skipped if ISIS is not running
+ * Success: The test will pass if all provided IS-IS instances have the correct data-plane configured.
+ * Failure: The test will fail if any of the provided IS-IS instances have an incorrect data-plane configured.
+ * Skipped: The test will be skipped if IS-IS is not configured.
Examples
--------
@@ -463,57 +321,37 @@ class VerifyISISSegmentRoutingDataplane(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyISISSegmentRoutingDataplane test."""
- instances: list[IsisInstance]
+ instances: list[ISISInstance]
+ """List of IS-IS instances with their information."""
+ IsisInstance: ClassVar[type[IsisInstance]] = IsisInstance
- class IsisInstance(BaseModel):
- """ISIS Instance model definition."""
-
- name: str
- """ISIS instance name."""
- vrf: str = "default"
- """VRF name where ISIS instance is configured."""
- dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS"
- """Configured dataplane for the instance."""
+ @field_validator("instances")
+ @classmethod
+ def validate_instances(cls, instances: list[ISISInstance]) -> list[ISISInstance]:
+ """Validate that 'vrf' field is 'default' in each IS-IS instance."""
+ for instance in instances:
+ if instance.vrf != "default":
+ msg = f"{instance} 'vrf' field must be 'default'"
+ raise ValueError(msg)
+ return instances
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyISISSegmentRoutingDataplane."""
- command_output = self.instance_commands[0].json_output
self.result.is_success()
- if len(command_output["vrfs"]) == 0:
- self.result.is_skipped("IS-IS-SR is not running on device.")
+ # Verify if IS-IS is configured
+ if not (command_output := self.instance_commands[0].json_output["vrfs"]):
+ self.result.is_skipped("IS-IS not configured")
return
- # initiate defaults
- failure_message = []
- skip_vrfs = []
- skip_instances = []
-
- # Check if VRFs and instances are present in output.
for instance in self.inputs.instances:
- vrf_data = get_value(
- dictionary=command_output,
- key=f"vrfs.{instance.vrf}",
- default=None,
- )
- if vrf_data is None:
- skip_vrfs.append(instance.vrf)
- failure_message.append(f"VRF {instance.vrf} is not configured to run segment routing.")
+ if not (instance_data := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}", separator="..")):
+ self.result.is_failure(f"{instance} - Not configured")
+ continue
- elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None:
- skip_instances.append(instance.name)
- failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.")
-
- # Check Adjacency segments
- for instance in self.inputs.instances:
- if instance.vrf not in skip_vrfs and instance.name not in skip_instances:
- eos_dataplane = get_value(dictionary=command_output, key=f"vrfs.{instance.vrf}.isisInstances.{instance.name}.dataPlane", default=None)
- if instance.dataplane.upper() != eos_dataplane:
- failure_message.append(f"ISIS instance {instance.name} is not running dataplane {instance.dataplane} ({eos_dataplane})")
-
- if failure_message:
- self.result.is_failure("\n".join(failure_message))
+ if instance.dataplane.upper() != (dataplane := instance_data["dataPlane"]):
+ self.result.is_failure(f"{instance} - Data-plane not correctly configured - Expected: {instance.dataplane.upper()} Actual: {dataplane}")
class VerifyISISSegmentRoutingTunnels(AntaTest):
@@ -553,34 +391,9 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyISISSegmentRoutingTunnels test."""
- entries: list[Entry]
+ entries: list[Tunnel]
"""List of tunnels to check on device."""
-
- class Entry(BaseModel):
- """Definition of a tunnel entry."""
-
- endpoint: IPv4Network
- """Endpoint IP of the tunnel."""
- vias: list[Vias] | None = None
- """Optional list of path to reach endpoint."""
-
- class Vias(BaseModel):
- """Definition of a tunnel path."""
-
- nexthop: IPv4Address | None = None
- """Nexthop of the tunnel. If None, then it is not tested. Default: None"""
- type: Literal["ip", "tunnel"] | None = None
- """Type of the tunnel. If None, then it is not tested. Default: None"""
- interface: Interface | None = None
- """Interface of the tunnel. If None, then it is not tested. Default: None"""
- tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None
- """Computation method of the tunnel. If None, then it is not tested. Default: None"""
-
- def _eos_entry_lookup(self, search_value: IPv4Network, entries: dict[str, Any], search_key: str = "endpoint") -> dict[str, Any] | None:
- return next(
- (entry_value for entry_id, entry_value in entries.items() if str(entry_value[search_key]) == str(search_value)),
- None,
- )
+ Entry: ClassVar[type[Entry]] = Entry
@AntaTest.anta_test
def test(self) -> None:
@@ -589,142 +402,43 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
This method performs the main test logic for verifying ISIS Segment Routing tunnels.
It checks the command output, initiates defaults, and performs various checks on the tunnels.
"""
- command_output = self.instance_commands[0].json_output
self.result.is_success()
- # initiate defaults
- failure_message = []
-
+ command_output = self.instance_commands[0].json_output
if len(command_output["entries"]) == 0:
- self.result.is_skipped("IS-IS-SR is not running on device.")
+ self.result.is_skipped("IS-IS-SR not configured")
return
for input_entry in self.inputs.entries:
- eos_entry = self._eos_entry_lookup(search_value=input_entry.endpoint, entries=command_output["entries"])
- if eos_entry is None:
- failure_message.append(f"Tunnel to {input_entry} is not found.")
- elif input_entry.vias is not None:
- failure_src = []
+ entries = list(command_output["entries"].values())
+ if (eos_entry := get_item(entries, "endpoint", str(input_entry.endpoint))) is None:
+ self.result.is_failure(f"{input_entry} - Tunnel not found")
+ continue
+
+ if input_entry.vias is not None:
for via_input in input_entry.vias:
- if not self._check_tunnel_type(via_input, eos_entry):
- failure_src.append("incorrect tunnel type")
- if not self._check_tunnel_nexthop(via_input, eos_entry):
- failure_src.append("incorrect nexthop")
- if not self._check_tunnel_interface(via_input, eos_entry):
- failure_src.append("incorrect interface")
- if not self._check_tunnel_id(via_input, eos_entry):
- failure_src.append("incorrect tunnel ID")
+ via_search_result = any(self._via_matches(via_input, eos_via) for eos_via in eos_entry["vias"])
+ if not via_search_result:
+ self.result.is_failure(f"{input_entry} {via_input} - Tunnel is incorrect")
- if failure_src:
- failure_message.append(f"Tunnel to {input_entry.endpoint!s} is incorrect: {', '.join(failure_src)}")
-
- if failure_message:
- self.result.is_failure("\n".join(failure_message))
-
- def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
- """Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`.
+ def _via_matches(self, via_input: TunnelPath, eos_via: dict[str, Any]) -> bool:
+ """Check if the via input matches the eos via.
Parameters
----------
- via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
- The input tunnel type to check.
- eos_entry : dict[str, Any]
- The EOS entry containing the tunnel types.
+ via_input : TunnelPath
+ The input via to check.
+ eos_via : dict[str, Any]
+ The EOS via to compare against.
Returns
-------
bool
- True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise.
+ True if the via input matches the eos via, False otherwise.
"""
- if via_input.type is not None:
- return any(
- via_input.type
- == get_value(
- dictionary=eos_via,
- key="type",
- default="undefined",
- )
- for eos_via in eos_entry["vias"]
- )
- return True
-
- def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
- """Check if the tunnel nexthop matches the given input.
-
- Parameters
- ----------
- via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
- The input via object.
- eos_entry : dict[str, Any]
- The EOS entry dictionary.
-
- Returns
- -------
- bool
- True if the tunnel nexthop matches, False otherwise.
- """
- if via_input.nexthop is not None:
- return any(
- str(via_input.nexthop)
- == get_value(
- dictionary=eos_via,
- key="nexthop",
- default="undefined",
- )
- for eos_via in eos_entry["vias"]
- )
- return True
-
- def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
- """Check if the tunnel interface exists in the given EOS entry.
-
- Parameters
- ----------
- via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
- The input via object.
- eos_entry : dict[str, Any]
- The EOS entry dictionary.
-
- Returns
- -------
- bool
- True if the tunnel interface exists, False otherwise.
- """
- if via_input.interface is not None:
- return any(
- via_input.interface
- == get_value(
- dictionary=eos_via,
- key="interface",
- default="undefined",
- )
- for eos_via in eos_entry["vias"]
- )
- return True
-
- def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool:
- """Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias.
-
- Parameters
- ----------
- via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
- The input vias to check.
- eos_entry : dict[str, Any])
- The EOS entry to compare against.
-
- Returns
- -------
- bool
- True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise.
- """
- if via_input.tunnel_id is not None:
- return any(
- via_input.tunnel_id.upper()
- == get_value(
- dictionary=eos_via,
- key="tunnelId.type",
- default="undefined",
- ).upper()
- for eos_via in eos_entry["vias"]
- )
- return True
+ return (
+ (via_input.type is None or via_input.type == eos_via.get("type"))
+ and (via_input.nexthop is None or str(via_input.nexthop) == eos_via.get("nexthop"))
+ and (via_input.interface is None or via_input.interface == eos_via.get("interface"))
+ and (via_input.tunnel_id is None or via_input.tunnel_id.upper() == get_value(eos_via, "tunnelId.type", default="").upper())
+ )
diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py
index d5d12e2..a99ac18 100644
--- a/anta/tests/routing/ospf.py
+++ b/anta/tests/routing/ospf.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 OSPF tests."""
@@ -7,90 +7,15 @@
# mypy: disable-error-code=attr-defined
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, ClassVar
+from typing import TYPE_CHECKING, ClassVar
from anta.models import AntaCommand, AntaTest
+from anta.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
-def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int:
- """Count the number of OSPF neighbors.
-
- Parameters
- ----------
- ospf_neighbor_json
- The JSON output of the `show ip ospf neighbor` command.
-
- Returns
- -------
- int
- The number of OSPF neighbors.
-
- """
- count = 0
- for vrf_data in ospf_neighbor_json["vrfs"].values():
- for instance_data in vrf_data["instList"].values():
- count += len(instance_data.get("ospfNeighborEntries", []))
- return count
-
-
-def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]:
- """Return the OSPF neighbors whose adjacency state is not `full`.
-
- Parameters
- ----------
- ospf_neighbor_json
- The JSON output of the `show ip ospf neighbor` command.
-
- Returns
- -------
- list[dict[str, Any]]
- A list of OSPF neighbors whose adjacency state is not `full`.
-
- """
- return [
- {
- "vrf": vrf,
- "instance": instance,
- "neighbor": neighbor_data["routerId"],
- "state": state,
- }
- for vrf, vrf_data in ospf_neighbor_json["vrfs"].items()
- for instance, instance_data in vrf_data["instList"].items()
- for neighbor_data in instance_data.get("ospfNeighborEntries", [])
- if (state := neighbor_data["adjacencyState"]) != "full"
- ]
-
-
-def _get_ospf_max_lsa_info(ospf_process_json: dict[str, Any]) -> list[dict[str, Any]]:
- """Return information about OSPF instances and their LSAs.
-
- Parameters
- ----------
- ospf_process_json
- OSPF process information in JSON format.
-
- Returns
- -------
- list[dict[str, Any]]
- A list of dictionaries containing OSPF LSAs information.
-
- """
- return [
- {
- "vrf": vrf,
- "instance": instance,
- "maxLsa": instance_data.get("maxLsaInformation", {}).get("maxLsa"),
- "maxLsaThreshold": instance_data.get("maxLsaInformation", {}).get("maxLsaThreshold"),
- "numLsa": instance_data.get("lsaInformation", {}).get("numLsa"),
- }
- for vrf, vrf_data in ospf_process_json.get("vrfs", {}).items()
- for instance, instance_data in vrf_data.get("instList", {}).items()
- ]
-
-
class VerifyOSPFNeighborState(AntaTest):
"""Verifies all OSPF neighbors are in FULL state.
@@ -115,14 +40,29 @@ class VerifyOSPFNeighborState(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyOSPFNeighborState."""
- command_output = self.instance_commands[0].json_output
- if _count_ospf_neighbor(command_output) == 0:
- self.result.is_skipped("no OSPF neighbor found")
- return
self.result.is_success()
- not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
- if not_full_neighbors:
- self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
+
+ # If OSPF is not configured on device, test skipped.
+ if not (command_output := get_value(self.instance_commands[0].json_output, "vrfs")):
+ self.result.is_skipped("OSPF not configured")
+ return
+
+ no_neighbor = True
+ for vrf, vrf_data in command_output.items():
+ for instance, instance_data in vrf_data["instList"].items():
+ neighbors = instance_data["ospfNeighborEntries"]
+ if not neighbors:
+ continue
+ no_neighbor = False
+ interfaces = [(neighbor["routerId"], state) for neighbor in neighbors if (state := neighbor["adjacencyState"]) != "full"]
+ for interface in interfaces:
+ self.result.is_failure(
+ f"Instance: {instance} VRF: {vrf} Interface: {interface[0]} - Incorrect adjacency state - Expected: Full Actual: {interface[1]}"
+ )
+
+ # If OSPF neighbors are not configured on device, test skipped.
+ if no_neighbor:
+ self.result.is_skipped("No OSPF neighbor detected")
class VerifyOSPFNeighborCount(AntaTest):
@@ -156,20 +96,34 @@ class VerifyOSPFNeighborCount(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyOSPFNeighborCount."""
- command_output = self.instance_commands[0].json_output
- if (neighbor_count := _count_ospf_neighbor(command_output)) == 0:
- self.result.is_skipped("no OSPF neighbor found")
- return
self.result.is_success()
- if neighbor_count != self.inputs.number:
- self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})")
- not_full_neighbors = _get_not_full_ospf_neighbors(command_output)
- if not_full_neighbors:
- self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.")
+ # If OSPF is not configured on device, test skipped.
+ if not (command_output := get_value(self.instance_commands[0].json_output, "vrfs")):
+ self.result.is_skipped("OSPF not configured")
+ return
+
+ no_neighbor = True
+ interfaces = []
+ for vrf_data in command_output.values():
+ for instance_data in vrf_data["instList"].values():
+ neighbors = instance_data["ospfNeighborEntries"]
+ if not neighbors:
+ continue
+ no_neighbor = False
+ interfaces.extend([neighbor["routerId"] for neighbor in neighbors if neighbor["adjacencyState"] == "full"])
+
+ # If OSPF neighbors are not configured on device, test skipped.
+ if no_neighbor:
+ self.result.is_skipped("No OSPF neighbor detected")
+ return
+
+ # If the number of OSPF neighbors expected to be in the FULL state does not match with actual one, test fails.
+ if len(interfaces) != self.inputs.number:
+ self.result.is_failure(f"Neighbor count mismatch - Expected: {self.inputs.number} Actual: {len(interfaces)}")
class VerifyOSPFMaxLSA(AntaTest):
- """Verifies LSAs present in the OSPF link state database did not cross the maximum LSA Threshold.
+ """Verifies all OSPF instances did not cross the maximum LSA threshold.
Expected Results
----------------
@@ -186,23 +140,23 @@ class VerifyOSPFMaxLSA(AntaTest):
```
"""
- description = "Verifies all OSPF instances did not cross the maximum LSA threshold."
categories: ClassVar[list[str]] = ["ospf"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf", revision=1)]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyOSPFMaxLSA."""
- command_output = self.instance_commands[0].json_output
- ospf_instance_info = _get_ospf_max_lsa_info(command_output)
- if not ospf_instance_info:
- self.result.is_skipped("No OSPF instance found.")
+ self.result.is_success()
+
+ # If OSPF is not configured on device, test skipped.
+ if not (command_output := get_value(self.instance_commands[0].json_output, "vrfs")):
+ self.result.is_skipped("OSPF not configured")
return
- all_instances_within_threshold = all(instance["numLsa"] <= instance["maxLsa"] * (instance["maxLsaThreshold"] / 100) for instance in ospf_instance_info)
- if all_instances_within_threshold:
- self.result.is_success()
- else:
- exceeded_instances = [
- instance["instance"] for instance in ospf_instance_info if instance["numLsa"] > instance["maxLsa"] * (instance["maxLsaThreshold"] / 100)
- ]
- self.result.is_failure(f"OSPF Instances {exceeded_instances} crossed the maximum LSA threshold.")
+
+ for vrf_data in command_output.values():
+ for instance, instance_data in vrf_data.get("instList", {}).items():
+ max_lsa = instance_data["maxLsaInformation"]["maxLsa"]
+ max_lsa_threshold = instance_data["maxLsaInformation"]["maxLsaThreshold"]
+ num_lsa = get_value(instance_data, "lsaInformation.numLsa")
+ if num_lsa > (max_lsa_threshold := round(max_lsa * (max_lsa_threshold / 100))):
+ self.result.is_failure(f"Instance: {instance} - Crossed the maximum LSA threshold - Expected: < {max_lsa_threshold} Actual: {num_lsa}")
diff --git a/anta/tests/security.py b/anta/tests/security.py
index 38bf240..d026692 100644
--- a/anta/tests/security.py
+++ b/anta/tests/security.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various security tests."""
@@ -8,22 +8,12 @@ from __future__ import annotations
# Mypy does not understand AntaTest.Input typing
# mypy: disable-error-code=attr-defined
from datetime import datetime, timezone
-from typing import TYPE_CHECKING, ClassVar, get_args
+from typing import ClassVar
-from pydantic import BaseModel, Field, model_validator
-
-from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize
-from anta.input_models.security import IPSecPeer, IPSecPeers
+from anta.custom_types import PositiveInteger
+from anta.input_models.security import ACL, APISSLCertificate, IPSecPeer, IPSecPeers
from anta.models import AntaCommand, AntaTemplate, AntaTest
-from anta.tools import get_failed_logs, get_item, get_value
-
-if TYPE_CHECKING:
- import sys
-
- if sys.version_info >= (3, 11):
- from typing import Self
- else:
- from typing_extensions import Self
+from anta.tools import get_item, get_value
class VerifySSHStatus(AntaTest):
@@ -53,7 +43,7 @@ class VerifySSHStatus(AntaTest):
try:
line = next(line for line in command_output.split("\n") if line.startswith("SSHD status"))
except StopIteration:
- self.result.is_failure("Could not find SSH status in returned output.")
+ self.result.is_failure("Could not find SSH status in returned output")
return
status = line.split()[-1]
@@ -96,19 +86,18 @@ class VerifySSHIPv4Acl(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySSHIPv4Acl."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list)
if ipv4_acl_number != self.inputs.number:
- self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - SSH IPv4 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv4_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
- self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
- else:
- self.result.is_success()
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SSH IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}")
class VerifySSHIPv6Acl(AntaTest):
@@ -144,19 +133,18 @@ class VerifySSHIPv6Acl(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySSHIPv6Acl."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list)
if ipv6_acl_number != self.inputs.number:
- self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - SSH IPv6 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv6_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
- self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
- else:
- self.result.is_success()
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SSH IPv6 ACL(s) not configured or active: {', '.join(not_configured_acl)}")
class VerifyTelnetStatus(AntaTest):
@@ -218,7 +206,7 @@ class VerifyAPIHttpStatus(AntaTest):
class VerifyAPIHttpsSSL(AntaTest):
- """Verifies if eAPI HTTPS server SSL profile is configured and valid.
+ """Verifies if the eAPI has a valid SSL profile.
Expected Results
----------------
@@ -234,7 +222,6 @@ class VerifyAPIHttpsSSL(AntaTest):
```
"""
- description = "Verifies if the eAPI has a valid SSL profile."
categories: ClassVar[list[str]] = ["security"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)]
@@ -252,10 +239,10 @@ class VerifyAPIHttpsSSL(AntaTest):
if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid":
self.result.is_success()
else:
- self.result.is_failure(f"eAPI HTTPS server SSL profile ({self.inputs.profile}) is misconfigured or invalid")
+ self.result.is_failure(f"eAPI HTTPS server SSL profile {self.inputs.profile} is misconfigured or invalid")
except KeyError:
- self.result.is_failure(f"eAPI HTTPS server SSL profile ({self.inputs.profile}) is not configured")
+ self.result.is_failure(f"eAPI HTTPS server SSL profile {self.inputs.profile} is not configured")
class VerifyAPIIPv4Acl(AntaTest):
@@ -294,13 +281,13 @@ class VerifyAPIIPv4Acl(AntaTest):
ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list)
if ipv4_acl_number != self.inputs.number:
- self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - eAPI IPv4 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv4_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
- self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Following eAPI IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}")
else:
self.result.is_success()
@@ -342,13 +329,13 @@ class VerifyAPIIPv6Acl(AntaTest):
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list)
if ipv6_acl_number != self.inputs.number:
- self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - eAPI IPv6 ACL(s) count mismatch - Expected: {self.inputs.number} Actual: {ipv6_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
- self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Following eAPI IPv6 ACL(s) not configured or active: {', '.join(not_configured_acl)}")
else:
self.result.is_success()
@@ -356,12 +343,25 @@ class VerifyAPIIPv6Acl(AntaTest):
class VerifyAPISSLCertificate(AntaTest):
"""Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
+ This test performs the following checks for each certificate:
+
+ 1. Validates that the certificate is not expired and meets the configured expiry threshold.
+ 2. Validates that the certificate Common Name matches the expected one.
+ 3. Ensures the certificate uses the specified encryption algorithm.
+ 4. Verifies the certificate key matches the expected key size.
+
Expected Results
----------------
- * Success: The test will pass if the certificate's expiry date is greater than the threshold,
- and the certificate has the correct name, encryption algorithm, and key size.
- * Failure: The test will fail if the certificate is expired or is going to expire,
- or if the certificate has an incorrect name, encryption algorithm, or key size.
+ * Success: If all of the following occur:
+ - The certificate's expiry date exceeds the configured threshold.
+ - The certificate's Common Name matches the input configuration.
+ - The encryption algorithm used by the certificate is as expected.
+ - The key size of the certificate matches the input configuration.
+ * Failure: If any of the following occur:
+ - The certificate is expired or set to expire within the defined threshold.
+ - The certificate's common name does not match the expected input.
+ - The encryption algorithm is incorrect.
+ - The key size does not match the expected input.
Examples
--------
@@ -393,38 +393,7 @@ class VerifyAPISSLCertificate(AntaTest):
certificates: list[APISSLCertificate]
"""List of API SSL certificates."""
-
- class APISSLCertificate(BaseModel):
- """Model for an API SSL certificate."""
-
- certificate_name: str
- """The name of the certificate to be verified."""
- expiry_threshold: int
- """The expiry threshold of the certificate in days."""
- common_name: str
- """The common subject name of the certificate."""
- encryption_algorithm: EncryptionAlgorithm
- """The encryption algorithm of the certificate."""
- key_size: RsaKeySize | EcdsaKeySize
- """The encryption algorithm key size of the certificate."""
-
- @model_validator(mode="after")
- def validate_inputs(self) -> Self:
- """Validate the key size provided to the APISSLCertificates class.
-
- If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
-
- If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
- """
- if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
- msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
- raise ValueError(msg)
-
- if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
- msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
- raise ValueError(msg)
-
- return self
+ APISSLCertificate: ClassVar[type[APISSLCertificate]] = APISSLCertificate
@AntaTest.anta_test
def test(self) -> None:
@@ -442,7 +411,7 @@ class VerifyAPISSLCertificate(AntaTest):
# Collecting certificate expiry time and current EOS time.
# These times are used to calculate the number of days until the certificate expires.
if not (certificate_data := get_value(certificate_output, f"certificates..{certificate.certificate_name}", separator="..")):
- self.result.is_failure(f"SSL certificate '{certificate.certificate_name}', is not configured.\n")
+ self.result.is_failure(f"{certificate} - Not found")
continue
expiry_time = certificate_data["notAfter"]
@@ -450,24 +419,25 @@ class VerifyAPISSLCertificate(AntaTest):
# Verify certificate expiry
if 0 < day_difference < certificate.expiry_threshold:
- self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is about to expire in {day_difference} days.\n")
+ self.result.is_failure(
+ f"{certificate} - set to expire within the threshold - Threshold: {certificate.expiry_threshold} days Actual: {day_difference} days"
+ )
elif day_difference < 0:
- self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is expired.\n")
+ self.result.is_failure(f"{certificate} - certificate expired")
# Verify certificate common subject name, encryption algorithm and key size
- keys_to_verify = ["subject.commonName", "publicKey.encryptionAlgorithm", "publicKey.size"]
- actual_certificate_details = {key: get_value(certificate_data, key) for key in keys_to_verify}
+ common_name = get_value(certificate_data, "subject.commonName", default="Not found")
+ encryp_algo = get_value(certificate_data, "publicKey.encryptionAlgorithm", default="Not found")
+ key_size = get_value(certificate_data, "publicKey.size", default="Not found")
- expected_certificate_details = {
- "subject.commonName": certificate.common_name,
- "publicKey.encryptionAlgorithm": certificate.encryption_algorithm,
- "publicKey.size": certificate.key_size,
- }
+ if common_name != certificate.common_name:
+ self.result.is_failure(f"{certificate} - incorrect common name - Expected: {certificate.common_name} Actual: {common_name}")
- if actual_certificate_details != expected_certificate_details:
- failed_log = f"SSL certificate `{certificate.certificate_name}` is not configured properly:"
- failed_log += get_failed_logs(expected_certificate_details, actual_certificate_details)
- self.result.is_failure(f"{failed_log}\n")
+ if encryp_algo != certificate.encryption_algorithm:
+ self.result.is_failure(f"{certificate} - incorrect encryption algorithm - Expected: {certificate.encryption_algorithm} Actual: {encryp_algo}")
+
+ if key_size != certificate.key_size:
+ self.result.is_failure(f"{certificate} - incorrect public key - Expected: {certificate.key_size} Actual: {key_size}")
class VerifyBannerLogin(AntaTest):
@@ -502,14 +472,15 @@ class VerifyBannerLogin(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBannerLogin."""
- login_banner = self.instance_commands[0].json_output["loginBanner"]
+ self.result.is_success()
+ if not (login_banner := self.instance_commands[0].json_output["loginBanner"]):
+ self.result.is_failure("Login banner is not configured")
+ return
# Remove leading and trailing whitespaces from each line
cleaned_banner = "\n".join(line.strip() for line in self.inputs.login_banner.split("\n"))
if login_banner != cleaned_banner:
- self.result.is_failure(f"Expected `{cleaned_banner}` as the login banner, but found `{login_banner}` instead.")
- else:
- self.result.is_success()
+ self.result.is_failure(f"Incorrect login banner configured - Expected: {cleaned_banner} Actual: {login_banner}")
class VerifyBannerMotd(AntaTest):
@@ -544,23 +515,34 @@ class VerifyBannerMotd(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyBannerMotd."""
- motd_banner = self.instance_commands[0].json_output["motd"]
+ self.result.is_success()
+ if not (motd_banner := self.instance_commands[0].json_output["motd"]):
+ self.result.is_failure("MOTD banner is not configured")
+ return
# Remove leading and trailing whitespaces from each line
cleaned_banner = "\n".join(line.strip() for line in self.inputs.motd_banner.split("\n"))
if motd_banner != cleaned_banner:
- self.result.is_failure(f"Expected `{cleaned_banner}` as the motd banner, but found `{motd_banner}` instead.")
- else:
- self.result.is_success()
+ self.result.is_failure(f"Incorrect MOTD banner configured - Expected: {cleaned_banner} Actual: {motd_banner}")
class VerifyIPv4ACL(AntaTest):
"""Verifies the configuration of IPv4 ACLs.
+ This test performs the following checks for each IPv4 ACL:
+
+ 1. Validates that the IPv4 ACL is properly configured.
+ 2. Validates that the sequence entries in the ACL are correctly ordered.
+
Expected Results
----------------
- * Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
- * Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
+ * Success: If all of the following occur:
+ - Any IPv4 ACL entry is not configured.
+ - The sequency entries are correctly configured.
+ * Failure: If any of the following occur:
+ - The IPv4 ACL is not configured.
+ - The any IPv4 ACL entry is not configured.
+ - The action for any entry does not match the expected input.
Examples
--------
@@ -586,65 +568,37 @@ class VerifyIPv4ACL(AntaTest):
"""
categories: ClassVar[list[str]] = ["security"]
- commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip access-lists", revision=1)]
class Input(AntaTest.Input):
"""Input model for the VerifyIPv4ACL test."""
- ipv4_access_lists: list[IPv4ACL]
+ ipv4_access_lists: list[ACL]
"""List of IPv4 ACLs to verify."""
-
- class IPv4ACL(BaseModel):
- """Model for an IPv4 ACL."""
-
- name: str
- """Name of IPv4 ACL."""
-
- entries: list[IPv4ACLEntry]
- """List of IPv4 ACL entries."""
-
- class IPv4ACLEntry(BaseModel):
- """Model for an IPv4 ACL entry."""
-
- sequence: int = Field(ge=1, le=4294967295)
- """Sequence number of an ACL entry."""
- action: str
- """Action of an ACL entry."""
-
- def render(self, template: AntaTemplate) -> list[AntaCommand]:
- """Render the template for each input ACL."""
- return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists]
+ IPv4ACL: ClassVar[type[ACL]] = ACL
+ """To maintain backward compatibility."""
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyIPv4ACL."""
self.result.is_success()
- for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists):
- # Collecting input ACL details
- acl_name = command_output.params.acl
- # Retrieve the expected entries from the inputs
- acl_entries = acl.entries
- # Check if ACL is configured
- ipv4_acl_list = command_output.json_output["aclList"]
- if not ipv4_acl_list:
- self.result.is_failure(f"{acl_name}: Not found")
+ if not (command_output := self.instance_commands[0].json_output["aclList"]):
+ self.result.is_failure("No Access Control List (ACL) configured")
+ return
+
+ for access_list in self.inputs.ipv4_access_lists:
+ if not (access_list_output := get_item(command_output, "name", access_list.name)):
+ self.result.is_failure(f"{access_list} - Not configured")
continue
- # Check if the sequence number is configured and has the correct action applied
- failed_log = f"{acl_name}:\n"
- for acl_entry in acl_entries:
- acl_seq = acl_entry.sequence
- acl_action = acl_entry.action
- if (actual_entry := get_item(ipv4_acl_list[0]["sequence"], "sequenceNumber", acl_seq)) is None:
- failed_log += f"Sequence number `{acl_seq}` is not found.\n"
+ for entry in access_list.entries:
+ if not (actual_entry := get_item(access_list_output["sequence"], "sequenceNumber", entry.sequence)):
+ self.result.is_failure(f"{access_list} {entry} - Not configured")
continue
- if actual_entry["text"] != acl_action:
- failed_log += f"Expected `{acl_action}` as sequence number {acl_seq} action but found `{actual_entry['text']}` instead.\n"
-
- if failed_log != f"{acl_name}:\n":
- self.result.is_failure(f"{failed_log}")
+ if (act_action := actual_entry["text"]) != entry.action:
+ self.result.is_failure(f"{access_list} {entry} - action mismatch - Expected: {entry.action} Actual: {act_action}")
class VerifyIPSecConnHealth(AntaTest):
@@ -670,12 +624,11 @@ class VerifyIPSecConnHealth(AntaTest):
def test(self) -> None:
"""Main test function for VerifyIPSecConnHealth."""
self.result.is_success()
- failure_conn = []
command_output = self.instance_commands[0].json_output["connections"]
# Check if IP security connection is configured
if not command_output:
- self.result.is_failure("No IPv4 security connection configured.")
+ self.result.is_failure("No IPv4 security connection configured")
return
# Iterate over all ipsec connections
@@ -685,10 +638,7 @@ class VerifyIPSecConnHealth(AntaTest):
source = conn_data.get("saddr")
destination = conn_data.get("daddr")
vrf = conn_data.get("tunnelNs")
- failure_conn.append(f"source:{source} destination:{destination} vrf:{vrf}")
- if failure_conn:
- failure_msg = "\n".join(failure_conn)
- self.result.is_failure(f"The following IPv4 security connections are not established:\n{failure_msg}.")
+ self.result.is_failure(f"Source: {source} Destination: {destination} VRF: {vrf} - IPv4 security connection not established")
class VerifySpecificIPSecConn(AntaTest):
@@ -763,9 +713,7 @@ class VerifySpecificIPSecConn(AntaTest):
if state != "Established":
source = conn_data.get("saddr")
destination = conn_data.get("daddr")
- self.result.is_failure(
- f"{input_peer} Source: {source} Destination: {destination} - Connection down - Expected: Established, Actual: {state}"
- )
+ self.result.is_failure(f"{input_peer} Source: {source} Destination: {destination} - Connection down - Expected: Established Actual: {state}")
continue
# Create a dictionary of existing connections for faster lookup
@@ -780,7 +728,7 @@ class VerifySpecificIPSecConn(AntaTest):
if (source_input, destination_input, vrf) in existing_connections:
existing_state = existing_connections[(source_input, destination_input, vrf)]
if existing_state != "Established":
- failure = f"Expected: Established, Actual: {existing_state}"
+ failure = f"Expected: Established Actual: {existing_state}"
self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection down - {failure}")
else:
self.result.is_failure(f"{input_peer} Source: {source_input} Destination: {destination_input} - Connection not found.")
@@ -812,6 +760,6 @@ class VerifyHardwareEntropy(AntaTest):
# Check if hardware entropy generation is enabled.
if not command_output.get("hardwareEntropyEnabled"):
- self.result.is_failure("Hardware entropy generation is disabled.")
+ self.result.is_failure("Hardware entropy generation is disabled")
else:
self.result.is_success()
diff --git a/anta/tests/services.py b/anta/tests/services.py
index dab1b3a..a2b09da 100644
--- a/anta/tests/services.py
+++ b/anta/tests/services.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various services tests."""
@@ -9,12 +9,9 @@ from __future__ import annotations
# mypy: disable-error-code=attr-defined
from typing import ClassVar
-from pydantic import BaseModel
-
-from anta.custom_types import ErrDisableInterval, ErrDisableReasons
-from anta.input_models.services import DnsServer
+from anta.input_models.services import DnsServer, ErrDisableReason, ErrdisableRecovery
from anta.models import AntaCommand, AntaTemplate, AntaTest
-from anta.tools import get_dict_superset, get_failed_logs
+from anta.tools import get_dict_superset, get_item
class VerifyHostname(AntaTest):
@@ -49,7 +46,7 @@ class VerifyHostname(AntaTest):
hostname = self.instance_commands[0].json_output["hostname"]
if hostname != self.inputs.hostname:
- self.result.is_failure(f"Expected `{self.inputs.hostname}` as the hostname, but found `{hostname}` instead.")
+ self.result.is_failure(f"Incorrect Hostname - Expected: {self.inputs.hostname} Actual: {hostname}")
else:
self.result.is_success()
@@ -166,12 +163,24 @@ class VerifyDNSServers(AntaTest):
class VerifyErrdisableRecovery(AntaTest):
- """Verifies the errdisable recovery reason, status, and interval.
+ """Verifies the error disable recovery functionality.
+
+ This test performs the following checks for each specified error disable reason:
+
+ 1. Verifying if the specified error disable reason exists.
+ 2. Checking if the recovery timer status matches the expected enabled/disabled state.
+ 3. Validating that the timer interval matches the configured value.
Expected Results
----------------
- * Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input.
- * Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input.
+ * Success: The test will pass if:
+ - The specified error disable reason exists.
+ - The recovery timer status matches the expected state.
+ - The timer interval matches the configured value.
+ * Failure: The test will fail if:
+ - The specified error disable reason does not exist.
+ - The recovery timer status does not match the expected state.
+ - The timer interval does not match the configured value.
Examples
--------
@@ -181,8 +190,10 @@ class VerifyErrdisableRecovery(AntaTest):
reasons:
- reason: acl
interval: 30
+ status: Enabled
- reason: bpduguard
interval: 30
+ status: Enabled
```
"""
@@ -193,44 +204,35 @@ class VerifyErrdisableRecovery(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyErrdisableRecovery test."""
- reasons: list[ErrDisableReason]
+ reasons: list[ErrdisableRecovery]
"""List of errdisable reasons."""
-
- class ErrDisableReason(BaseModel):
- """Model for an errdisable reason."""
-
- reason: ErrDisableReasons
- """Type or name of the errdisable reason."""
- interval: ErrDisableInterval
- """Interval of the reason in seconds."""
+ ErrDisableReason: ClassVar[type[ErrdisableRecovery]] = ErrDisableReason
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyErrdisableRecovery."""
- command_output = self.instance_commands[0].text_output
self.result.is_success()
+
+ # Skip header and last empty line
+ command_output = self.instance_commands[0].text_output.split("\n")[2:-1]
+
+ # Collecting the actual errdisable reasons for faster lookup
+ errdisable_reasons = [
+ {"reason": reason, "status": status, "interval": interval}
+ for line in command_output
+ if line.strip() # Skip empty lines
+ for reason, status, interval in [line.split(None, 2)] # Unpack split result
+ ]
+
for error_reason in self.inputs.reasons:
- input_reason = error_reason.reason
- input_interval = error_reason.interval
- reason_found = False
+ if not (reason_output := get_item(errdisable_reasons, "reason", error_reason.reason)):
+ self.result.is_failure(f"{error_reason} - Not found")
+ continue
- # Skip header and last empty line
- lines = command_output.split("\n")[2:-1]
- for line in lines:
- # Skip empty lines
- if not line.strip():
- continue
- # Split by first two whitespaces
- reason, status, interval = line.split(None, 2)
- if reason != input_reason:
- continue
- reason_found = True
- actual_reason_data = {"interval": interval, "status": status}
- expected_reason_data = {"interval": str(input_interval), "status": "Enabled"}
- if actual_reason_data != expected_reason_data:
- failed_log = get_failed_logs(expected_reason_data, actual_reason_data)
- self.result.is_failure(f"`{input_reason}`:{failed_log}\n")
- break
-
- if not reason_found:
- self.result.is_failure(f"`{input_reason}`: Not found.\n")
+ if not all(
+ [
+ error_reason.status == (act_status := reason_output["status"]),
+ error_reason.interval == (act_interval := int(reason_output["interval"])),
+ ]
+ ):
+ self.result.is_failure(f"{error_reason} - Incorrect configuration - Status: {act_status} Interval: {act_interval}")
diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py
index b8bd73d..1d02252 100644
--- a/anta/tests/snmp.py
+++ b/anta/tests/snmp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS various SNMP tests."""
@@ -9,7 +9,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, get_args
+from pydantic import field_validator
+
from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu
+from anta.input_models.snmp import SnmpGroup, SnmpHost, SnmpSourceInterface, SnmpUser
from anta.models import AntaCommand, AntaTest
from anta.tools import get_value
@@ -18,7 +21,7 @@ if TYPE_CHECKING:
class VerifySnmpStatus(AntaTest):
- """Verifies whether the SNMP agent is enabled in a specified VRF.
+ """Verifies if the SNMP agent is enabled.
Expected Results
----------------
@@ -34,7 +37,6 @@ class VerifySnmpStatus(AntaTest):
```
"""
- description = "Verifies if the SNMP agent is enabled."
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
@@ -47,15 +49,14 @@ class VerifySnmpStatus(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpStatus."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]:
- self.result.is_success()
- else:
- self.result.is_failure(f"SNMP agent disabled in vrf {self.inputs.vrf}")
+ if not (command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]):
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - SNMP agent disabled")
class VerifySnmpIPv4Acl(AntaTest):
- """Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF.
+ """Verifies if the SNMP agent has IPv4 ACL(s) configured.
Expected Results
----------------
@@ -72,7 +73,6 @@ class VerifySnmpIPv4Acl(AntaTest):
```
"""
- description = "Verifies if the SNMP agent has IPv4 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)]
@@ -87,23 +87,22 @@ class VerifySnmpIPv4Acl(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpIPv4Acl."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
ipv4_acl_list = command_output["ipAclList"]["aclList"]
ipv4_acl_number = len(ipv4_acl_list)
if ipv4_acl_number != self.inputs.number:
- self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Incorrect SNMP IPv4 ACL(s) - Expected: {self.inputs.number} Actual: {ipv4_acl_number}")
return
not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if not_configured_acl:
- self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}")
- else:
- self.result.is_success()
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SNMP IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}")
class VerifySnmpIPv6Acl(AntaTest):
- """Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF.
+ """Verifies if the SNMP agent has IPv6 ACL(s) configured.
Expected Results
----------------
@@ -120,7 +119,6 @@ class VerifySnmpIPv6Acl(AntaTest):
```
"""
- description = "Verifies if the SNMP agent has IPv6 ACL(s) configured."
categories: ClassVar[list[str]] = ["snmp"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)]
@@ -136,18 +134,17 @@ class VerifySnmpIPv6Acl(AntaTest):
def test(self) -> None:
"""Main test function for VerifySnmpIPv6Acl."""
command_output = self.instance_commands[0].json_output
+ self.result.is_success()
ipv6_acl_list = command_output["ipv6AclList"]["aclList"]
ipv6_acl_number = len(ipv6_acl_list)
if ipv6_acl_number != self.inputs.number:
- self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}")
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Incorrect SNMP IPv6 ACL(s) - Expected: {self.inputs.number} Actual: {ipv6_acl_number}")
return
acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]]
if acl_not_configured:
- self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}")
- else:
- self.result.is_success()
+ self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SNMP IPv6 ACL(s) not configured or active: {', '.join(acl_not_configured)}")
class VerifySnmpLocation(AntaTest):
@@ -179,16 +176,15 @@ class VerifySnmpLocation(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpLocation."""
+ self.result.is_success()
# Verifies the SNMP location is configured.
if not (location := get_value(self.instance_commands[0].json_output, "location.location")):
- self.result.is_failure("SNMP location is not configured.")
+ self.result.is_failure("SNMP location is not configured")
return
# Verifies the expected SNMP location.
if location != self.inputs.location:
- self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.")
- else:
- self.result.is_success()
+ self.result.is_failure(f"Incorrect SNMP location - Expected: {self.inputs.location} Actual: {location}")
class VerifySnmpContact(AntaTest):
@@ -220,16 +216,15 @@ class VerifySnmpContact(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpContact."""
+ self.result.is_success()
# Verifies the SNMP contact is configured.
if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")):
- self.result.is_failure("SNMP contact is not configured.")
+ self.result.is_failure("SNMP contact is not configured")
return
# Verifies the expected SNMP contact.
if contact != self.inputs.contact:
- self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
- else:
- self.result.is_success()
+ self.result.is_failure(f"Incorrect SNMP contact - Expected: {self.inputs.contact} Actual: {contact}")
class VerifySnmpPDUCounters(AntaTest):
@@ -266,25 +261,24 @@ class VerifySnmpPDUCounters(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpPDUCounters."""
+ self.result.is_success()
snmp_pdus = self.inputs.pdus
command_output = self.instance_commands[0].json_output
# Verify SNMP PDU counters.
if not (pdu_counters := get_value(command_output, "counters")):
- self.result.is_failure("SNMP counters not found.")
+ self.result.is_failure("SNMP counters not found")
return
# In case SNMP PDUs not provided, It will check all the update error counters.
if not snmp_pdus:
snmp_pdus = list(get_args(SnmpPdu))
- failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
+ failures = {pdu for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
# Check if any failures
- if not failures:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}")
+ if failures:
+ self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters: {', '.join(sorted(failures))}")
class VerifySnmpErrorCounters(AntaTest):
@@ -320,6 +314,7 @@ class VerifySnmpErrorCounters(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySnmpErrorCounters."""
+ self.result.is_success()
error_counters = self.inputs.error_counters
command_output = self.instance_commands[0].json_output
@@ -332,10 +327,400 @@ class VerifySnmpErrorCounters(AntaTest):
if not error_counters:
error_counters = list(get_args(SnmpErrorCounter))
- error_counters_not_ok = {counter: value for counter in error_counters if (value := snmp_counters.get(counter))}
+ error_counters_not_ok = {counter for counter in error_counters if snmp_counters.get(counter)}
# Check if any failures
- if not error_counters_not_ok:
- self.result.is_success()
- else:
- self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}")
+ if error_counters_not_ok:
+ self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters: {', '.join(sorted(error_counters_not_ok))}")
+
+
+class VerifySnmpHostLogging(AntaTest):
+ """Verifies SNMP logging configurations.
+
+ This test performs the following checks:
+
+ 1. SNMP logging is enabled globally.
+ 2. For each specified SNMP host:
+ - Host exists in configuration.
+ - Host's VRF assignment matches expected value.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all of the following conditions are met:
+ - SNMP logging is enabled on the device.
+ - All specified hosts are configured with correct VRF assignments.
+ * Failure: The test will fail if any of the following conditions is met:
+ - SNMP logging is disabled on the device.
+ - SNMP host not found in configuration.
+ - Host's VRF assignment doesn't match expected value.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.snmp:
+ - VerifySnmpHostLogging:
+ hosts:
+ - hostname: 192.168.1.100
+ vrf: default
+ - hostname: 192.168.1.103
+ vrf: MGMT
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["snmp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifySnmpHostLogging test."""
+
+ hosts: list[SnmpHost]
+ """List of SNMP hosts."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySnmpHostLogging."""
+ self.result.is_success()
+
+ command_output = self.instance_commands[0].json_output.get("logging", {})
+ # If SNMP logging is disabled, test fails.
+ if not command_output.get("loggingEnabled"):
+ self.result.is_failure("SNMP logging is disabled")
+ return
+
+ host_details = command_output.get("hosts", {})
+
+ for host in self.inputs.hosts:
+ hostname = str(host.hostname)
+ vrf = host.vrf
+ actual_snmp_host = host_details.get(hostname, {})
+
+ # If SNMP host is not configured on the device, test fails.
+ if not actual_snmp_host:
+ self.result.is_failure(f"{host} - Not configured")
+ continue
+
+ # If VRF is not matches the expected value, test fails.
+ actual_vrf = "default" if (vrf_name := actual_snmp_host.get("vrf")) == "" else vrf_name
+ if actual_vrf != vrf:
+ self.result.is_failure(f"{host} - Incorrect VRF - Actual: {actual_vrf}")
+
+
+class VerifySnmpUser(AntaTest):
+ """Verifies the SNMP user configurations.
+
+ This test performs the following checks for each specified user:
+
+ 1. User exists in SNMP configuration.
+ 2. Group assignment is correct.
+ 3. For SNMPv3 users only:
+ - Authentication type matches (if specified)
+ - Privacy type matches (if specified)
+
+ Expected Results
+ ----------------
+ * Success: If all of the following conditions are met:
+ - All users exist with correct group assignments.
+ - SNMPv3 authentication and privacy types match specified values.
+ * Failure: If any of the following occur:
+ - User not found in SNMP configuration.
+ - Incorrect group assignment.
+ - For SNMPv3: Mismatched authentication or privacy types.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.snmp:
+ - VerifySnmpUser:
+ snmp_users:
+ - username: test
+ group_name: test_group
+ version: v3
+ auth_type: MD5
+ priv_type: AES-128
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["snmp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp user", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifySnmpUser test."""
+
+ snmp_users: list[SnmpUser]
+ """List of SNMP users."""
+
+ @field_validator("snmp_users")
+ @classmethod
+ def validate_snmp_users(cls, snmp_users: list[SnmpUser]) -> list[SnmpUser]:
+ """Validate that 'auth_type' or 'priv_type' field is provided in each SNMPv3 user."""
+ for user in snmp_users:
+ if user.version == "v3" and not (user.auth_type or user.priv_type):
+ msg = f"{user} 'auth_type' or 'priv_type' field is required with 'version: v3'"
+ raise ValueError(msg)
+ return snmp_users
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySnmpUser."""
+ self.result.is_success()
+
+ for user in self.inputs.snmp_users:
+ # Verify SNMP user details.
+ if not (user_details := get_value(self.instance_commands[0].json_output, f"usersByVersion.{user.version}.users.{user.username}")):
+ self.result.is_failure(f"{user} - Not found")
+ continue
+
+ if user.group_name != (act_group := user_details.get("groupName", "Not Found")):
+ self.result.is_failure(f"{user} - Incorrect user group - Actual: {act_group}")
+
+ if user.version == "v3":
+ if user.auth_type and (act_auth_type := get_value(user_details, "v3Params.authType", "Not Found")) != user.auth_type:
+ self.result.is_failure(f"{user} - Incorrect authentication type - Expected: {user.auth_type} Actual: {act_auth_type}")
+
+ if user.priv_type and (act_encryption := get_value(user_details, "v3Params.privType", "Not Found")) != user.priv_type:
+ self.result.is_failure(f"{user} - Incorrect privacy type - Expected: {user.priv_type} Actual: {act_encryption}")
+
+
+class VerifySnmpNotificationHost(AntaTest):
+ """Verifies the SNMP notification host(s) (SNMP manager) configurations.
+
+ This test performs the following checks for each specified host:
+
+ 1. Verifies that the SNMP host(s) is configured on the device.
+ 2. Verifies that the notification type ("trap" or "inform") matches the expected value.
+ 3. Ensures that UDP port provided matches the expected value.
+ 4. Ensures the following depending on SNMP version:
+ - For SNMP version v1/v2c, a valid community string is set and matches the expected value.
+ - For SNMP version v3, a valid user field is set and matches the expected value.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all of the following conditions are met:
+ - The SNMP host(s) is configured on the device.
+ - The notification type ("trap" or "inform") and UDP port match the expected value.
+ - Ensures the following depending on SNMP version:
+ - For SNMP version v1/v2c, a community string is set and it matches the expected value.
+ - For SNMP version v3, a valid user field is set and matches the expected value.
+ * Failure: The test will fail if any of the following conditions is met:
+ - The SNMP host(s) is not configured on the device.
+ - The notification type ("trap" or "inform") or UDP port do not matches the expected value.
+ - Ensures the following depending on SNMP version:
+ - For SNMP version v1/v2c, a community string is not matches the expected value.
+ - For SNMP version v3, an user field is not matches the expected value.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.snmp:
+ - VerifySnmpNotificationHost:
+ notification_hosts:
+ - hostname: spine
+ vrf: default
+ notification_type: trap
+ version: v1
+ udp_port: 162
+ community_string: public
+ - hostname: 192.168.1.100
+ vrf: default
+ notification_type: trap
+ version: v3
+ udp_port: 162
+ user: public
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["snmp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp notification host", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifySnmpNotificationHost test."""
+
+ notification_hosts: list[SnmpHost]
+ """List of SNMP host(s)."""
+
+ @field_validator("notification_hosts")
+ @classmethod
+ def validate_notification_hosts(cls, notification_hosts: list[SnmpHost]) -> list[SnmpHost]:
+ """Validate that all required fields are provided in each SNMP Notification Host."""
+ for host in notification_hosts:
+ if host.version is None:
+ msg = f"{host}; 'version' field missing in the input"
+ raise ValueError(msg)
+ if host.version in ["v1", "v2c"] and host.community_string is None:
+ msg = f"{host} Version: {host.version}; 'community_string' field missing in the input"
+ raise ValueError(msg)
+ if host.version == "v3" and host.user is None:
+ msg = f"{host} Version: {host.version}; 'user' field missing in the input"
+ raise ValueError(msg)
+ return notification_hosts
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySnmpNotificationHost."""
+ self.result.is_success()
+
+ # If SNMP is not configured, test fails.
+ if not (snmp_hosts := get_value(self.instance_commands[0].json_output, "hosts")):
+ self.result.is_failure("No SNMP host is configured")
+ return
+
+ for host in self.inputs.notification_hosts:
+ vrf = "" if host.vrf == "default" else host.vrf
+ hostname = str(host.hostname)
+ notification_type = host.notification_type
+ version = host.version
+ udp_port = host.udp_port
+ community_string = host.community_string
+ user = host.user
+ default_value = "Not Found"
+
+ host_details = next(
+ (host for host in snmp_hosts if (host.get("hostname") == hostname and host.get("protocolVersion") == version and host.get("vrf") == vrf)), None
+ )
+ # If expected SNMP host is not configured with the specified protocol version, test fails.
+ if not host_details:
+ self.result.is_failure(f"{host} Version: {version} - Not configured")
+ continue
+
+ # If actual notification type does not match the expected value, test fails.
+ if notification_type != (actual_notification_type := get_value(host_details, "notificationType", default_value)):
+ self.result.is_failure(f"{host} - Incorrect notification type - Expected: {notification_type} Actual: {actual_notification_type}")
+
+ # If actual UDP port does not match the expected value, test fails.
+ if udp_port != (actual_udp_port := get_value(host_details, "port", default_value)):
+ self.result.is_failure(f"{host} - Incorrect UDP port - Expected: {udp_port} Actual: {actual_udp_port}")
+
+ user_found = user != (actual_user := get_value(host_details, "v3Params.user", default_value))
+ version_user_check = (version == "v3", user_found)
+
+ # If SNMP protocol version is v1 or v2c and actual community string does not match the expected value, test fails.
+ if version in ["v1", "v2c"] and community_string != (actual_community_string := get_value(host_details, "v1v2cParams.communityString", default_value)):
+ self.result.is_failure(f"{host} Version: {version} - Incorrect community string - Expected: {community_string} Actual: {actual_community_string}")
+
+ # If SNMP protocol version is v3 and actual user does not match the expected value, test fails.
+ elif all(version_user_check):
+ self.result.is_failure(f"{host} Version: {version} - Incorrect user - Expected: {user} Actual: {actual_user}")
+
+
+class VerifySnmpSourceInterface(AntaTest):
+ """Verifies SNMP source interfaces.
+
+ This test performs the following checks:
+
+ 1. Verifies that source interface(s) are configured for SNMP.
+ 2. For each specified source interface:
+ - Interface is configured in the specified VRF.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if the provided SNMP source interface(s) are configured in their specified VRF.
+ * Failure: The test will fail if any of the provided SNMP source interface(s) are NOT configured in their specified VRF.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.snmp:
+ - VerifySnmpSourceInterface:
+ interfaces:
+ - interface: Ethernet1
+ vrf: default
+ - interface: Management0
+ vrf: MGMT
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["snmp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifySnmpSourceInterface test."""
+
+ interfaces: list[SnmpSourceInterface]
+ """List of source interfaces."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySnmpSourceInterface."""
+ self.result.is_success()
+ command_output = self.instance_commands[0].json_output.get("srcIntf", {})
+
+ if not (interface_output := command_output.get("sourceInterfaces")):
+ self.result.is_failure("SNMP source interface(s) not configured")
+ return
+
+ for interface_details in self.inputs.interfaces:
+ # If the source interface is not configured, or if it does not match the expected value, the test fails.
+ if not (actual_interface := interface_output.get(interface_details.vrf)):
+ self.result.is_failure(f"{interface_details} - Not configured")
+ elif actual_interface != interface_details.interface:
+ self.result.is_failure(f"{interface_details} - Incorrect source interface - Actual: {actual_interface}")
+
+
+class VerifySnmpGroup(AntaTest):
+ """Verifies the SNMP group configurations for specified version(s).
+
+ This test performs the following checks:
+
+ 1. Verifies that the SNMP group is configured for the specified version.
+ 2. For SNMP version 3, verify that the security model matches the expected value.
+ 3. Ensures that SNMP group configurations, including read, write, and notify views, align with version-specific requirements.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if the provided SNMP group and all specified parameters are correctly configured.
+ * Failure: The test will fail if the provided SNMP group is not configured or if any specified parameter is not correctly configured.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.snmp:
+ - VerifySnmpGroup:
+ snmp_groups:
+ - group_name: Group1
+ version: v1
+ read_view: group_read_1
+ write_view: group_write_1
+ notify_view: group_notify_1
+ - group_name: Group2
+ version: v3
+ read_view: group_read_2
+ write_view: group_write_2
+ notify_view: group_notify_2
+ authentication: priv
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["snmp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp group", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifySnmpGroup test."""
+
+ snmp_groups: list[SnmpGroup]
+ """List of SNMP groups."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySnmpGroup."""
+ self.result.is_success()
+ for group in self.inputs.snmp_groups:
+ # Verify SNMP group details.
+ if not (group_details := get_value(self.instance_commands[0].json_output, f"groups.{group.group_name}.versions.{group.version}")):
+ self.result.is_failure(f"{group} - Not configured")
+ continue
+
+ view_types = [view_type for view_type in ["read", "write", "notify"] if getattr(group, f"{view_type}_view")]
+ # Verify SNMP views, the read, write and notify settings aligning with version-specific requirements.
+ for view_type in view_types:
+ expected_view = getattr(group, f"{view_type}_view")
+ # Verify actual view is configured.
+ if group_details.get(f"{view_type}View") == "":
+ self.result.is_failure(f"{group} View: {view_type} - Not configured")
+ elif (act_view := group_details.get(f"{view_type}View")) != expected_view:
+ self.result.is_failure(f"{group} - Incorrect {view_type.title()} view - Expected: {expected_view} Actual: {act_view}")
+ elif not group_details.get(f"{view_type}ViewConfig"):
+ self.result.is_failure(f"{group} {view_type.title()} View: {expected_view} - Not configured")
+
+ # For version v3, verify that the security model aligns with the expected value.
+ if group.version == "v3" and (actual_auth := group_details.get("secModel")) != group.authentication:
+ self.result.is_failure(f"{group} - Incorrect security model - Expected: {group.authentication} Actual: {actual_auth}")
diff --git a/anta/tests/software.py b/anta/tests/software.py
index 9a41881..8251760 100644
--- a/anta/tests/software.py
+++ b/anta/tests/software.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Module related to the EOS software tests."""
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
class VerifyEOSVersion(AntaTest):
- """Verifies that the device is running one of the allowed EOS version.
+ """Verifies the EOS version of the device.
Expected Results
----------------
@@ -34,7 +34,6 @@ class VerifyEOSVersion(AntaTest):
```
"""
- description = "Verifies the EOS version of the device."
categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)]
@@ -48,14 +47,13 @@ class VerifyEOSVersion(AntaTest):
def test(self) -> None:
"""Main test function for VerifyEOSVersion."""
command_output = self.instance_commands[0].json_output
- if command_output["version"] in self.inputs.versions:
- self.result.is_success()
- else:
- self.result.is_failure(f'device is running version "{command_output["version"]}" not in expected versions: {self.inputs.versions}')
+ self.result.is_success()
+ if command_output["version"] not in self.inputs.versions:
+ self.result.is_failure(f"EOS version mismatch - Actual: {command_output['version']} not in Expected: {', '.join(self.inputs.versions)}")
class VerifyTerminAttrVersion(AntaTest):
- """Verifies that he device is running one of the allowed TerminAttr version.
+ """Verifies the TerminAttr version of the device.
Expected Results
----------------
@@ -73,7 +71,6 @@ class VerifyTerminAttrVersion(AntaTest):
```
"""
- description = "Verifies the TerminAttr version of the device."
categories: ClassVar[list[str]] = ["software"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)]
@@ -87,11 +84,10 @@ class VerifyTerminAttrVersion(AntaTest):
def test(self) -> None:
"""Main test function for VerifyTerminAttrVersion."""
command_output = self.instance_commands[0].json_output
+ self.result.is_success()
command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"]
- if command_output_data in self.inputs.versions:
- self.result.is_success()
- else:
- self.result.is_failure(f"device is running TerminAttr version {command_output_data} and is not in the allowed list: {self.inputs.versions}")
+ if command_output_data not in self.inputs.versions:
+ self.result.is_failure(f"TerminAttr version mismatch - Actual: {command_output_data} not in Expected: {', '.join(self.inputs.versions)}")
class VerifyEOSExtensions(AntaTest):
@@ -120,6 +116,7 @@ class VerifyEOSExtensions(AntaTest):
def test(self) -> None:
"""Main test function for VerifyEOSExtensions."""
boot_extensions = []
+ self.result.is_success()
show_extensions_command_output = self.instance_commands[0].json_output
show_boot_extensions_command_output = self.instance_commands[1].json_output
installed_extensions = [
@@ -131,7 +128,7 @@ class VerifyEOSExtensions(AntaTest):
boot_extensions.append(formatted_extension)
installed_extensions.sort()
boot_extensions.sort()
- if installed_extensions == boot_extensions:
- self.result.is_success()
- else:
- self.result.is_failure(f"Missing EOS extensions: installed {installed_extensions} / configured: {boot_extensions}")
+ if installed_extensions != boot_extensions:
+ str_installed_extensions = ", ".join(installed_extensions) if installed_extensions else "Not found"
+ str_boot_extensions = ", ".join(boot_extensions) if boot_extensions else "Not found"
+ self.result.is_failure(f"EOS extensions mismatch - Installed: {str_installed_extensions} Configured: {str_boot_extensions}")
diff --git a/anta/tests/stp.py b/anta/tests/stp.py
index 93a0d2e..47dfb9f 100644
--- a/anta/tests/stp.py
+++ b/anta/tests/stp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 various Spanning Tree Protocol (STP) tests."""
@@ -7,7 +7,7 @@
# mypy: disable-error-code=attr-defined
from __future__ import annotations
-from typing import Any, ClassVar, Literal
+from typing import ClassVar, Literal
from pydantic import Field
@@ -54,8 +54,7 @@ class VerifySTPMode(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPMode."""
- not_configured = []
- wrong_stp_mode = []
+ self.result.is_success()
for command in self.instance_commands:
vlan_id = command.params.vlan
if not (
@@ -64,15 +63,9 @@ class VerifySTPMode(AntaTest):
f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol",
)
):
- not_configured.append(vlan_id)
+ self.result.is_failure(f"VLAN {vlan_id} STP mode: {self.inputs.mode} - Not configured")
elif stp_mode != self.inputs.mode:
- wrong_stp_mode.append(vlan_id)
- if not_configured:
- self.result.is_failure(f"STP mode '{self.inputs.mode}' not configured for the following VLAN(s): {not_configured}")
- if wrong_stp_mode:
- self.result.is_failure(f"Wrong STP mode configured for the following VLAN(s): {wrong_stp_mode}")
- if not not_configured and not wrong_stp_mode:
- self.result.is_success()
+ self.result.is_failure(f"VLAN {vlan_id} - Incorrect STP mode - Expected: {self.inputs.mode} Actual: {stp_mode}")
class VerifySTPBlockedPorts(AntaTest):
@@ -102,8 +95,8 @@ class VerifySTPBlockedPorts(AntaTest):
self.result.is_success()
else:
for key, value in stp_instances.items():
- stp_instances[key] = value.pop("spanningTreeBlockedPorts")
- self.result.is_failure(f"The following ports are blocked by STP: {stp_instances}")
+ stp_block_ports = value.get("spanningTreeBlockedPorts")
+ self.result.is_failure(f"STP Instance: {key} - Blocked ports - {', '.join(stp_block_ports)}")
class VerifySTPCounters(AntaTest):
@@ -128,14 +121,14 @@ class VerifySTPCounters(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPCounters."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- interfaces_with_errors = [
- interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0
- ]
- if interfaces_with_errors:
- self.result.is_failure(f"The following interfaces have STP BPDU packet errors: {interfaces_with_errors}")
- else:
- self.result.is_success()
+
+ for interface, counters in command_output["interfaces"].items():
+ if counters["bpduTaggedError"] != 0:
+ self.result.is_failure(f"Interface {interface} - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: {counters['bpduTaggedError']}")
+ if counters["bpduOtherError"] != 0:
+ self.result.is_failure(f"Interface {interface} - STP BPDU packet other errors count mismatch - Expected: 0 Actual: {counters['bpduOtherError']}")
class VerifySTPForwardingPorts(AntaTest):
@@ -174,25 +167,22 @@ class VerifySTPForwardingPorts(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPForwardingPorts."""
- not_configured = []
- not_forwarding = []
+ self.result.is_success()
+ interfaces_state = []
for command in self.instance_commands:
vlan_id = command.params.vlan
if not (topologies := get_value(command.json_output, "topologies")):
- not_configured.append(vlan_id)
- else:
- interfaces_not_forwarding = []
- for value in topologies.values():
- if vlan_id and int(vlan_id) in value["vlans"]:
- interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"]
- if interfaces_not_forwarding:
- not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding})
- if not_configured:
- self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}")
- if not_forwarding:
- self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}")
- if not not_configured and not interfaces_not_forwarding:
- self.result.is_success()
+ self.result.is_failure(f"VLAN {vlan_id} - STP instance is not configured")
+ continue
+ for value in topologies.values():
+ if vlan_id and int(vlan_id) in value["vlans"]:
+ interfaces_state = [
+ (interface, actual_state) for interface, state in value["interfaces"].items() if (actual_state := state["state"]) != "forwarding"
+ ]
+
+ if interfaces_state:
+ for interface, state in interfaces_state:
+ self.result.is_failure(f"VLAN {vlan_id} Interface: {interface} - Invalid state - Expected: forwarding Actual: {state}")
class VerifySTPRootPriority(AntaTest):
@@ -229,6 +219,7 @@ class VerifySTPRootPriority(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifySTPRootPriority."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
if not (stp_instances := command_output["instances"]):
self.result.is_failure("No STP instances configured")
@@ -240,16 +231,15 @@ class VerifySTPRootPriority(AntaTest):
elif first_name.startswith("VL"):
prefix = "VL"
else:
- self.result.is_failure(f"Unsupported STP instance type: {first_name}")
+ self.result.is_failure(f"STP Instance: {first_name} - Unsupported STP instance type")
return
check_instances = [f"{prefix}{instance_id}" for instance_id in self.inputs.instances] if self.inputs.instances else command_output["instances"].keys()
- wrong_priority_instances = [
- instance for instance in check_instances if get_value(command_output, f"instances.{instance}.rootBridge.priority") != self.inputs.priority
- ]
- if wrong_priority_instances:
- self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}")
- else:
- self.result.is_success()
+ for instance in check_instances:
+ if not (instance_details := get_value(command_output, f"instances.{instance}")):
+ self.result.is_failure(f"Instance: {instance} - Not configured")
+ continue
+ if (priority := get_value(instance_details, "rootBridge.priority")) != self.inputs.priority:
+ self.result.is_failure(f"STP Instance: {instance} - Incorrect root priority - Expected: {self.inputs.priority} Actual: {priority}")
class VerifyStpTopologyChanges(AntaTest):
@@ -282,8 +272,7 @@ class VerifyStpTopologyChanges(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyStpTopologyChanges."""
- failures: dict[str, Any] = {"topologies": {}}
-
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
stp_topologies = command_output.get("topologies", {})
@@ -292,20 +281,78 @@ class VerifyStpTopologyChanges(AntaTest):
# Verify the STP topology(s).
if not stp_topologies:
- self.result.is_failure("STP is not configured.")
+ self.result.is_failure("STP is not configured")
return
# Verifies the number of changes across all interfaces
for topology, topology_details in stp_topologies.items():
- interfaces = {
- interface: {"Number of changes": num_of_changes}
- for interface, details in topology_details.get("interfaces", {}).items()
- if (num_of_changes := details.get("numChanges")) > self.inputs.threshold
- }
- if interfaces:
- failures["topologies"][topology] = interfaces
+ for interface, details in topology_details.get("interfaces", {}).items():
+ if (num_of_changes := details.get("numChanges")) > self.inputs.threshold:
+ self.result.is_failure(
+ f"Topology: {topology} Interface: {interface} - Number of changes not within the threshold - Expected: "
+ f"{self.inputs.threshold} Actual: {num_of_changes}"
+ )
- if failures["topologies"]:
- self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}")
- else:
- self.result.is_success()
+
+class VerifySTPDisabledVlans(AntaTest):
+ """Verifies the STP disabled VLAN(s).
+
+ This test performs the following checks:
+
+ 1. Verifies that the STP is configured.
+ 2. Verifies that the specified VLAN(s) exist on the device.
+ 3. Verifies that the STP is disabled for the specified VLAN(s).
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all of the following conditions are met:
+ - STP is properly configured on the device.
+ - The specified VLAN(s) exist on the device.
+ - STP is confirmed to be disabled for all the specified VLAN(s).
+ * Failure: The test will fail if any of the following condition is met:
+ - STP is not configured on the device.
+ - The specified VLAN(s) do not exist on the device.
+ - STP is enabled for any of the specified VLAN(s).
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.stp:
+ - VerifySTPDisabledVlans:
+ vlans:
+ - 6
+ - 4094
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["stp"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree vlan detail", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifySTPDisabledVlans test."""
+
+ vlans: list[Vlan]
+ """List of STP disabled VLAN(s)."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifySTPDisabledVlans."""
+ self.result.is_success()
+
+ command_output = self.instance_commands[0].json_output
+ stp_vlan_instances = command_output.get("spanningTreeVlanInstances", {})
+
+ # If the spanningTreeVlanInstances detail are not found in the command output, the test fails.
+ if not stp_vlan_instances:
+ self.result.is_failure("STP is not configured")
+ return
+
+ actual_vlans = list(stp_vlan_instances)
+ # If the specified VLAN is not present on the device, STP is enabled for the VLAN(s), test fails.
+ for vlan in self.inputs.vlans:
+ if str(vlan) not in actual_vlans:
+ self.result.is_failure(f"VLAN: {vlan} - Not configured")
+ continue
+
+ if stp_vlan_instances.get(str(vlan)):
+ self.result.is_failure(f"VLAN: {vlan} - STP is enabled")
diff --git a/anta/tests/stun.py b/anta/tests/stun.py
index 2be13c4..da8c281 100644
--- a/anta/tests/stun.py
+++ b/anta/tests/stun.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test functions related to various STUN settings."""
@@ -76,7 +76,7 @@ class VerifyStunClientTranslation(AntaTest):
# If no bindings are found for the STUN client, mark the test as a failure and continue with the next client
if not bindings:
- self.result.is_failure(f"{client_input} - STUN client translation not found.")
+ self.result.is_failure(f"{client_input} - STUN client translation not found")
continue
# Extract the transaction ID from the bindings
@@ -145,10 +145,10 @@ class VerifyStunServer(AntaTest):
not_running = command_output.get("pid") == 0
if status_disabled and not_running:
- self.result.is_failure("STUN server status is disabled and not running.")
+ self.result.is_failure("STUN server status is disabled and not running")
elif status_disabled:
- self.result.is_failure("STUN server status is disabled.")
+ self.result.is_failure("STUN server status is disabled")
elif not_running:
- self.result.is_failure("STUN server is not running.")
+ self.result.is_failure("STUN server is not running")
else:
self.result.is_success()
diff --git a/anta/tests/system.py b/anta/tests/system.py
index cceced6..048f987 100644
--- a/anta/tests/system.py
+++ b/anta/tests/system.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 system-level features and protocols tests."""
@@ -8,23 +8,33 @@
from __future__ import annotations
import re
-from typing import TYPE_CHECKING, ClassVar
+from typing import TYPE_CHECKING, Any, ClassVar
-from anta.custom_types import PositiveInteger
-from anta.input_models.system import NTPServer
+from pydantic import model_validator
+
+from anta.custom_types import Hostname, PositiveInteger
+from anta.input_models.system import NTPPool, NTPServer
from anta.models import AntaCommand, AntaTest
from anta.tools import get_value
if TYPE_CHECKING:
+ import sys
+ from ipaddress import IPv4Address
+
from anta.models import AntaTemplate
+ if sys.version_info >= (3, 11):
+ from typing import Self
+ else:
+ from typing_extensions import Self
+
CPU_IDLE_THRESHOLD = 25
MEMORY_THRESHOLD = 0.25
DISK_SPACE_THRESHOLD = 75
class VerifyUptime(AntaTest):
- """Verifies if the device uptime is higher than the provided minimum uptime value.
+ """Verifies the device uptime.
Expected Results
----------------
@@ -40,7 +50,6 @@ class VerifyUptime(AntaTest):
```
"""
- description = "Verifies the device uptime."
categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)]
@@ -53,11 +62,10 @@ class VerifyUptime(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyUptime."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- if command_output["upTime"] > self.inputs.minimum:
- self.result.is_success()
- else:
- self.result.is_failure(f"Device uptime is {command_output['upTime']} seconds")
+ if command_output["upTime"] < self.inputs.minimum:
+ self.result.is_failure(f"Device uptime is incorrect - Expected: {self.inputs.minimum}s Actual: {command_output['upTime']}s")
class VerifyReloadCause(AntaTest):
@@ -96,11 +104,11 @@ class VerifyReloadCause(AntaTest):
]:
self.result.is_success()
else:
- self.result.is_failure(f"Reload cause is: '{command_output_data}'")
+ self.result.is_failure(f"Reload cause is: {command_output_data}")
class VerifyCoredump(AntaTest):
- """Verifies if there are core dump files in the /var/core directory.
+ """Verifies there are no core dump files.
Expected Results
----------------
@@ -119,7 +127,6 @@ class VerifyCoredump(AntaTest):
```
"""
- description = "Verifies there are no core dump files."
categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)]
@@ -133,7 +140,7 @@ class VerifyCoredump(AntaTest):
if not core_files:
self.result.is_success()
else:
- self.result.is_failure(f"Core dump(s) have been found: {core_files}")
+ self.result.is_failure(f"Core dump(s) have been found: {', '.join(core_files)}")
class VerifyAgentLogs(AntaTest):
@@ -189,12 +196,11 @@ class VerifyCPUUtilization(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyCPUUtilization."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"]
- if command_output_data > CPU_IDLE_THRESHOLD:
- self.result.is_success()
- else:
- self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%")
+ if command_output_data < CPU_IDLE_THRESHOLD:
+ self.result.is_failure(f"Device has reported a high CPU utilization - Expected: < 75% Actual: {100 - command_output_data}%")
class VerifyMemoryUtilization(AntaTest):
@@ -219,12 +225,11 @@ class VerifyMemoryUtilization(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyMemoryUtilization."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
memory_usage = command_output["memFree"] / command_output["memTotal"]
- if memory_usage > MEMORY_THRESHOLD:
- self.result.is_success()
- else:
- self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%")
+ if memory_usage < MEMORY_THRESHOLD:
+ self.result.is_failure(f"Device has reported a high memory usage - Expected: < 75% Actual: {(1 - memory_usage) * 100:.2f}%")
class VerifyFileSystemUtilization(AntaTest):
@@ -253,11 +258,11 @@ class VerifyFileSystemUtilization(AntaTest):
self.result.is_success()
for line in command_output.split("\n")[1:]:
if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > DISK_SPACE_THRESHOLD:
- self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%")
+ self.result.is_failure(f"Mount point: {line} - Higher disk space utilization - Expected: {DISK_SPACE_THRESHOLD}% Actual: {percentage}%")
class VerifyNTP(AntaTest):
- """Verifies that the Network Time Protocol (NTP) is synchronized.
+ """Verifies if NTP is synchronised.
Expected Results
----------------
@@ -272,7 +277,6 @@ class VerifyNTP(AntaTest):
```
"""
- description = "Verifies if NTP is synchronised."
categories: ClassVar[list[str]] = ["system"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")]
@@ -284,18 +288,27 @@ class VerifyNTP(AntaTest):
self.result.is_success()
else:
data = command_output.split("\n")[0]
- self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'")
+ self.result.is_failure(f"NTP status mismatch - Expected: synchronised Actual: {data}")
class VerifyNTPAssociations(AntaTest):
"""Verifies the Network Time Protocol (NTP) associations.
+ This test performs the following checks:
+
+ 1. For the NTP servers:
+ - The primary NTP server (marked as preferred) has the condition 'sys.peer'.
+ - All other NTP servers have the condition 'candidate'.
+ - All the NTP servers have the expected stratum level.
+ 2. For the NTP servers pool:
+ - All the NTP servers belong to the specified NTP pool.
+ - All the NTP servers have valid condition (sys.peer | candidate).
+ - All the NTP servers have the stratum level within the specified startum level.
+
Expected Results
----------------
- * Success: The test will pass if the Primary NTP server (marked as preferred) has the condition 'sys.peer' and
- all other NTP servers have the condition 'candidate'.
- * Failure: The test will fail if the Primary NTP server (marked as preferred) does not have the condition 'sys.peer' or
- if any other NTP server does not have the condition 'candidate'.
+ * Success: The test will pass if all the NTP servers meet the expected state.
+ * Failure: The test will fail if any of the NTP server does not meet the expected state.
Examples
--------
@@ -310,6 +323,10 @@ class VerifyNTPAssociations(AntaTest):
stratum: 2
- server_address: 3.3.3.3
stratum: 2
+ - VerifyNTPAssociations:
+ ntp_pool:
+ server_addresses: [1.1.1.1, 2.2.2.2]
+ preferred_stratum_range: [1,3]
```
"""
@@ -319,10 +336,79 @@ class VerifyNTPAssociations(AntaTest):
class Input(AntaTest.Input):
"""Input model for the VerifyNTPAssociations test."""
- ntp_servers: list[NTPServer]
+ ntp_servers: list[NTPServer] | None = None
"""List of NTP servers."""
+ ntp_pool: NTPPool | None = None
+ """NTP servers pool."""
NTPServer: ClassVar[type[NTPServer]] = NTPServer
+ @model_validator(mode="after")
+ def validate_inputs(self) -> Self:
+ """Validate the inputs provided to the VerifyNTPAssociations test.
+
+ Either `ntp_servers` or `ntp_pool` can be provided at the same time.
+ """
+ if not self.ntp_servers and not self.ntp_pool:
+ msg = "'ntp_servers' or 'ntp_pool' must be provided"
+ raise ValueError(msg)
+ if self.ntp_servers and self.ntp_pool:
+ msg = "Either 'ntp_servers' or 'ntp_pool' can be provided at the same time"
+ raise ValueError(msg)
+
+ # Verifies the len of preferred_stratum_range in NTP Pool should be 2 as this is the range.
+ stratum_range = 2
+ if self.ntp_pool and len(self.ntp_pool.preferred_stratum_range) > stratum_range:
+ msg = "'preferred_stratum_range' list should have at most 2 items"
+ raise ValueError(msg)
+ return self
+
+ def _validate_ntp_server(self, ntp_server: NTPServer, peers: dict[str, Any]) -> list[str]:
+ """Validate the NTP server, condition and stratum level."""
+ failure_msgs: list[str] = []
+ server_address = str(ntp_server.server_address)
+
+ # We check `peerIpAddr` in the peer details - covering IPv4Address input, or the peer key - covering Hostname input.
+ matching_peer = next((peer for peer, peer_details in peers.items() if (server_address in {peer_details["peerIpAddr"], peer})), None)
+
+ if not matching_peer:
+ failure_msgs.append(f"{ntp_server} - Not configured")
+ return failure_msgs
+
+ # Collecting the expected/actual NTP peer details.
+ exp_condition = "sys.peer" if ntp_server.preferred else "candidate"
+ exp_stratum = ntp_server.stratum
+ act_condition = get_value(peers[matching_peer], "condition")
+ act_stratum = get_value(peers[matching_peer], "stratumLevel")
+
+ if act_condition != exp_condition:
+ failure_msgs.append(f"{ntp_server} - Incorrect condition - Expected: {exp_condition} Actual: {act_condition}")
+
+ if act_stratum != exp_stratum:
+ failure_msgs.append(f"{ntp_server} - Incorrect stratum level - Expected: {exp_stratum} Actual: {act_stratum}")
+
+ return failure_msgs
+
+ def _validate_ntp_pool(self, server_addresses: list[Hostname | IPv4Address], peer: str, stratum_range: list[int], peer_details: dict[str, Any]) -> list[str]:
+ """Validate the NTP server pool, condition and stratum level."""
+ failure_msgs: list[str] = []
+
+ # We check `peerIpAddr` and `peer` in the peer details - covering server_addresses input
+ if (peer_ip := peer_details["peerIpAddr"]) not in server_addresses and peer not in server_addresses:
+ failure_msgs.append(f"NTP Server: {peer_ip} Hostname: {peer} - Associated but not part of the provided NTP pool")
+ return failure_msgs
+
+ act_condition = get_value(peer_details, "condition")
+ act_stratum = get_value(peer_details, "stratumLevel")
+
+ if act_condition not in ["sys.peer", "candidate"]:
+ failure_msgs.append(f"NTP Server: {peer_ip} Hostname: {peer} - Incorrect condition - Expected: sys.peer, candidate Actual: {act_condition}")
+
+ if int(act_stratum) not in range(stratum_range[0], stratum_range[1] + 1):
+ msg = f"Expected Stratum Range: {stratum_range[0]} to {stratum_range[1]} Actual: {act_stratum}"
+ failure_msgs.append(f"NTP Server: {peer_ip} Hostname: {peer} - Incorrect stratum level - {msg}")
+
+ return failure_msgs
+
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyNTPAssociations."""
@@ -332,22 +418,66 @@ class VerifyNTPAssociations(AntaTest):
self.result.is_failure("No NTP peers configured")
return
- # Iterate over each NTP server.
- for ntp_server in self.inputs.ntp_servers:
- server_address = str(ntp_server.server_address)
+ if self.inputs.ntp_servers:
+ # Iterate over each NTP server.
+ for ntp_server in self.inputs.ntp_servers:
+ failure_msgs = self._validate_ntp_server(ntp_server, peers)
+ for msg in failure_msgs:
+ self.result.is_failure(msg)
+ return
- # We check `peerIpAddr` in the peer details - covering IPv4Address input, or the peer key - covering Hostname input.
- matching_peer = next((peer for peer, peer_details in peers.items() if (server_address in {peer_details["peerIpAddr"], peer})), None)
+ # Verifies the NTP pool details
+ server_addresses = self.inputs.ntp_pool.server_addresses
+ exp_stratum_range = self.inputs.ntp_pool.preferred_stratum_range
+ for peer, peer_details in peers.items():
+ failure_msgs = self._validate_ntp_pool(server_addresses, peer, exp_stratum_range, peer_details)
+ for msg in failure_msgs:
+ self.result.is_failure(msg)
- if not matching_peer:
- self.result.is_failure(f"{ntp_server} - Not configured")
- continue
- # Collecting the expected/actual NTP peer details.
- exp_condition = "sys.peer" if ntp_server.preferred else "candidate"
- exp_stratum = ntp_server.stratum
- act_condition = get_value(peers[matching_peer], "condition")
- act_stratum = get_value(peers[matching_peer], "stratumLevel")
+class VerifyMaintenance(AntaTest):
+ """Verifies that the device is not currently under or entering maintenance.
- if act_condition != exp_condition or act_stratum != exp_stratum:
- self.result.is_failure(f"{ntp_server} - Bad association - Condition: {act_condition}, Stratum: {act_stratum}")
+ Expected Results
+ ----------------
+ * Success: The test will pass if the device is not under or entering maintenance.
+ * Failure: The test will fail if the device is under or entering maintenance.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.system:
+ - VerifyMaintenance:
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["Maintenance"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show maintenance", revision=1)]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyMaintenance."""
+ self.result.is_success()
+
+ # If units is not empty we have to examine the output for details.
+ if not (units := get_value(self.instance_commands[0].json_output, "units")):
+ return
+ units_under_maintenance = [unit for unit, info in units.items() if info["state"] == "underMaintenance"]
+ units_entering_maintenance = [unit for unit, info in units.items() if info["state"] == "maintenanceModeEnter"]
+ causes = set()
+ # Iterate over units, check for units under or entering maintenance, and examine the causes.
+ for info in units.values():
+ if info["adminState"] == "underMaintenance":
+ causes.add("Quiesce is configured")
+ if info["onBootMaintenance"]:
+ causes.add("On-boot maintenance is configured")
+ if info["intfsViolatingTrafficThreshold"]:
+ causes.add("Interface traffic threshold violation")
+
+ # Building the error message.
+ if units_under_maintenance:
+ self.result.is_failure(f"Units under maintenance: '{', '.join(units_under_maintenance)}'.")
+ if units_entering_maintenance:
+ self.result.is_failure(f"Units entering maintenance: '{', '.join(units_entering_maintenance)}'.")
+ if causes:
+ self.result.is_failure(f"Possible causes: '{', '.join(sorted(causes))}'.")
diff --git a/anta/tests/vlan.py b/anta/tests/vlan.py
index b7b1bd4..25fc9d5 100644
--- a/anta/tests/vlan.py
+++ b/anta/tests/vlan.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 VLAN tests."""
@@ -9,9 +9,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Literal
-from anta.custom_types import Vlan
+from anta.custom_types import DynamicVlanSource, Vlan
from anta.models import AntaCommand, AntaTest
-from anta.tools import get_failed_logs, get_value
+from anta.tools import get_value
if TYPE_CHECKING:
from anta.models import AntaTemplate
@@ -55,16 +55,93 @@ class VerifyVlanInternalPolicy(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyVlanInternalPolicy."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- keys_to_verify = ["policy", "startVlanId", "endVlanId"]
- actual_policy_output = {key: get_value(command_output, key) for key in keys_to_verify}
- expected_policy_output = {"policy": self.inputs.policy, "startVlanId": self.inputs.start_vlan_id, "endVlanId": self.inputs.end_vlan_id}
+ if (policy := self.inputs.policy) != (act_policy := get_value(command_output, "policy")):
+ self.result.is_failure(f"Incorrect VLAN internal allocation policy configured - Expected: {policy} Actual: {act_policy}")
+ return
- # Check if the actual output matches the expected output
- if actual_policy_output != expected_policy_output:
- failed_log = "The VLAN internal allocation policy is not configured properly:"
- failed_log += get_failed_logs(expected_policy_output, actual_policy_output)
- self.result.is_failure(failed_log)
- else:
- self.result.is_success()
+ if (start_vlan_id := self.inputs.start_vlan_id) != (act_vlan_id := get_value(command_output, "startVlanId")):
+ self.result.is_failure(
+ f"VLAN internal allocation policy: {self.inputs.policy} - Incorrect start VLAN id configured - Expected: {start_vlan_id} Actual: {act_vlan_id}"
+ )
+
+ if (end_vlan_id := self.inputs.end_vlan_id) != (act_vlan_id := get_value(command_output, "endVlanId")):
+ self.result.is_failure(
+ f"VLAN internal allocation policy: {self.inputs.policy} - Incorrect end VLAN id configured - Expected: {end_vlan_id} Actual: {act_vlan_id}"
+ )
+
+
+class VerifyDynamicVlanSource(AntaTest):
+ """Verifies dynamic VLAN allocation for specified VLAN sources.
+
+ This test performs the following checks for each specified VLAN source:
+
+ 1. Validates source exists in dynamic VLAN table.
+ 2. Verifies at least one VLAN is allocated to the source.
+ 3. When strict mode is enabled (`strict: true`), ensures no other sources have VLANs allocated.
+
+ Expected Results
+ ----------------
+ * Success: The test will pass if all of the following conditions are met:
+ - Each specified source exists in dynamic VLAN table.
+ - Each specified source has at least one VLAN allocated.
+ - In strict mode: No other sources have VLANs allocated.
+ * Failure: The test will fail if any of the following conditions is met:
+ - Specified source not found in configuration.
+ - Source exists but has no VLANs allocated.
+ - In strict mode: Non-specified sources have VLANs allocated.
+
+ Examples
+ --------
+ ```yaml
+ anta.tests.vlan:
+ - VerifyDynamicVlanSource:
+ sources:
+ - evpn
+ - mlagsync
+ strict: False
+ ```
+ """
+
+ categories: ClassVar[list[str]] = ["vlan"]
+ commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan dynamic", revision=1)]
+
+ class Input(AntaTest.Input):
+ """Input model for the VerifyDynamicVlanSource test."""
+
+ sources: list[DynamicVlanSource]
+ """The dynamic VLAN source list."""
+ strict: bool = False
+ """If True, only specified sources are allowed to have VLANs allocated. Default is False."""
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ """Main test function for VerifyDynamicVlanSource."""
+ self.result.is_success()
+ command_output = self.instance_commands[0].json_output
+ dynamic_vlans = command_output.get("dynamicVlans", {})
+
+ # Get all configured sources and sources with VLANs allocated
+ configured_sources = set(dynamic_vlans.keys())
+ sources_with_vlans = {source for source, data in dynamic_vlans.items() if data.get("vlanIds")}
+ expected_sources = set(self.inputs.sources)
+
+ # Check if all specified sources exist in configuration
+ missing_sources = expected_sources - configured_sources
+ if missing_sources:
+ self.result.is_failure(f"Dynamic VLAN source(s) not found in configuration: {', '.join(sorted(missing_sources))}")
+ return
+
+ # Check if configured sources have VLANs allocated
+ sources_without_vlans = expected_sources - sources_with_vlans
+ if sources_without_vlans:
+ self.result.is_failure(f"Dynamic VLAN source(s) exist but have no VLANs allocated: {', '.join(sorted(sources_without_vlans))}")
+ return
+
+ # In strict mode, verify no other sources have VLANs allocated
+ if self.inputs.strict:
+ unexpected_sources = sources_with_vlans - expected_sources
+ if unexpected_sources:
+ self.result.is_failure(f"Strict mode enabled: Unexpected sources have VLANs allocated: {', '.join(sorted(unexpected_sources))}")
diff --git a/anta/tests/vxlan.py b/anta/tests/vxlan.py
index e5f0a54..04c3994 100644
--- a/anta/tests/vxlan.py
+++ b/anta/tests/vxlan.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 VXLAN tests."""
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
class VerifyVxlan1Interface(AntaTest):
- """Verifies if the Vxlan1 interface is configured and 'up/up'.
+ """Verifies the Vxlan1 interface status.
Warnings
--------
@@ -41,26 +41,26 @@ class VerifyVxlan1Interface(AntaTest):
```
"""
- description = "Verifies the Vxlan1 interface status."
categories: ClassVar[list[str]] = ["vxlan"]
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)]
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyVxlan1Interface."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
- if "Vxlan1" not in command_output["interfaceDescriptions"]:
- self.result.is_skipped("Vxlan1 interface is not configured")
- elif (
- command_output["interfaceDescriptions"]["Vxlan1"]["lineProtocolStatus"] == "up"
- and command_output["interfaceDescriptions"]["Vxlan1"]["interfaceStatus"] == "up"
- ):
- self.result.is_success()
- else:
- self.result.is_failure(
- f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}"
- f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}",
- )
+
+ # Skipping the test if the Vxlan1 interface is not configured
+ if "Vxlan1" not in (interface_details := command_output["interfaceDescriptions"]):
+ self.result.is_skipped("Interface: Vxlan1 - Not configured")
+ return
+
+ line_protocol_status = interface_details["Vxlan1"]["lineProtocolStatus"]
+ interface_status = interface_details["Vxlan1"]["interfaceStatus"]
+
+ # Checking against both status and line protocol status
+ if interface_status != "up" or line_protocol_status != "up":
+ self.result.is_failure(f"Interface: Vxlan1 - Incorrect Line protocol status/Status - Expected: up/up Actual: {line_protocol_status}/{interface_status}")
class VerifyVxlanConfigSanity(AntaTest):
@@ -86,19 +86,19 @@ class VerifyVxlanConfigSanity(AntaTest):
@AntaTest.anta_test
def test(self) -> None:
"""Main test function for VerifyVxlanConfigSanity."""
+ self.result.is_success()
command_output = self.instance_commands[0].json_output
+
+ # Skipping the test if VXLAN is not configured
if "categories" not in command_output or len(command_output["categories"]) == 0:
self.result.is_skipped("VXLAN is not configured")
return
- failed_categories = {
- category: content
- for category, content in command_output["categories"].items()
- if category in ["localVtep", "mlag", "pd"] and content["allCheckPass"] is not True
- }
- if len(failed_categories) > 0:
- self.result.is_failure(f"VXLAN config sanity check is not passing: {failed_categories}")
- else:
- self.result.is_success()
+
+ # Verifies the Vxlan config sanity
+ categories_to_check = ["localVtep", "mlag", "pd"]
+ for category in categories_to_check:
+ if not get_value(command_output, f"categories.{category}.allCheckPass"):
+ self.result.is_failure(f"Vxlan Category: {category} - Config sanity check is not passing")
class VerifyVxlanVniBinding(AntaTest):
@@ -135,31 +135,23 @@ class VerifyVxlanVniBinding(AntaTest):
"""Main test function for VerifyVxlanVniBinding."""
self.result.is_success()
- no_binding = []
- wrong_binding = []
-
if (vxlan1 := get_value(self.instance_commands[0].json_output, "vxlanIntfs.Vxlan1")) is None:
self.result.is_skipped("Vxlan1 interface is not configured")
return
for vni, vlan in self.inputs.bindings.items():
str_vni = str(vni)
+ retrieved_vlan = ""
if str_vni in vxlan1["vniBindings"]:
- retrieved_vlan = vxlan1["vniBindings"][str_vni]["vlan"]
+ retrieved_vlan = get_value(vxlan1, f"vniBindings..{str_vni}..vlan", separator="..")
elif str_vni in vxlan1["vniBindingsToVrf"]:
- retrieved_vlan = vxlan1["vniBindingsToVrf"][str_vni]["vlan"]
- else:
- no_binding.append(str_vni)
- retrieved_vlan = None
+ retrieved_vlan = get_value(vxlan1, f"vniBindingsToVrf..{str_vni}..vlan", separator="..")
- if retrieved_vlan and vlan != retrieved_vlan:
- wrong_binding.append({str_vni: retrieved_vlan})
+ if not retrieved_vlan:
+ self.result.is_failure(f"Interface: Vxlan1 VNI: {str_vni} - Binding not found")
- if no_binding:
- self.result.is_failure(f"The following VNI(s) have no binding: {no_binding}")
-
- if wrong_binding:
- self.result.is_failure(f"The following VNI(s) have the wrong VLAN binding: {wrong_binding}")
+ elif vlan != retrieved_vlan:
+ self.result.is_failure(f"Interface: Vxlan1 VNI: {str_vni} VLAN: {vlan} - Wrong VLAN binding - Actual: {retrieved_vlan}")
class VerifyVxlanVtep(AntaTest):
@@ -206,10 +198,10 @@ class VerifyVxlanVtep(AntaTest):
difference2 = set(vxlan1["vteps"]).difference(set(inputs_vteps))
if difference1:
- self.result.is_failure(f"The following VTEP peer(s) are missing from the Vxlan1 interface: {sorted(difference1)}")
+ self.result.is_failure(f"The following VTEP peer(s) are missing from the Vxlan1 interface: {', '.join(sorted(difference1))}")
if difference2:
- self.result.is_failure(f"Unexpected VTEP peer(s) on Vxlan1 interface: {sorted(difference2)}")
+ self.result.is_failure(f"Unexpected VTEP peer(s) on Vxlan1 interface: {', '.join(sorted(difference2))}")
class VerifyVxlan1ConnSettings(AntaTest):
@@ -259,6 +251,6 @@ class VerifyVxlan1ConnSettings(AntaTest):
# Check vxlan1 source interface and udp port
if src_intf != self.inputs.source_interface:
- self.result.is_failure(f"Source interface is not correct. Expected `{self.inputs.source_interface}` as source interface but found `{src_intf}` instead.")
+ self.result.is_failure(f"Interface: Vxlan1 - Incorrect Source interface - Expected: {self.inputs.source_interface} Actual: {src_intf}")
if port != self.inputs.udp_port:
- self.result.is_failure(f"UDP port is not correct. Expected `{self.inputs.udp_port}` as UDP port but found `{port}` instead.")
+ self.result.is_failure(f"Interface: Vxlan1 - Incorrect UDP port - Expected: {self.inputs.udp_port} Actual: {port}")
diff --git a/anta/tools.py b/anta/tools.py
index 8b116a0..cbcfe0b 100644
--- a/anta/tools.py
+++ b/anta/tools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Common functions used in ANTA tests."""
@@ -353,7 +353,7 @@ def cprofile(sort_by: str = "cumtime") -> Callable[[F], F]:
return result
- return cast(F, wrapper)
+ return cast("F", wrapper)
return decorator
diff --git a/asynceapi/__init__.py b/asynceapi/__init__.py
index 6d5a23b..fedb07f 100644
--- a/asynceapi/__init__.py
+++ b/asynceapi/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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
diff --git a/asynceapi/_constants.py b/asynceapi/_constants.py
new file mode 100644
index 0000000..2904038
--- /dev/null
+++ b/asynceapi/_constants.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2024-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Constants and Enums for the asynceapi package."""
+
+from __future__ import annotations
+
+from enum import Enum
+
+
+class EapiCommandFormat(str, Enum):
+ """Enum for the eAPI command format.
+
+ NOTE: This could be updated to StrEnum when Python 3.11 is the minimum supported version in ANTA.
+ """
+
+ JSON = "json"
+ TEXT = "text"
+
+ def __str__(self) -> str:
+ """Override the __str__ method to return the value of the Enum, mimicking the behavior of StrEnum."""
+ return self.value
diff --git a/asynceapi/_errors.py b/asynceapi/_errors.py
new file mode 100644
index 0000000..321843d
--- /dev/null
+++ b/asynceapi/_errors.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2024-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Exceptions for the asynceapi package."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from asynceapi._models import EapiResponse
+
+
+class EapiReponseError(Exception):
+ """Exception raised when an eAPI response contains errors.
+
+ Attributes
+ ----------
+ response : EapiResponse
+ The eAPI response that contains the error.
+ """
+
+ def __init__(self, response: EapiResponse) -> None:
+ """Initialize the EapiReponseError exception."""
+ self.response = response
+
+ # Build a descriptive error message
+ message = "Error in eAPI response"
+
+ if response.error_code is not None:
+ message += f" (code: {response.error_code})"
+
+ if response.error_message is not None:
+ message += f": {response.error_message}"
+
+ super().__init__(message)
diff --git a/asynceapi/_models.py b/asynceapi/_models.py
new file mode 100644
index 0000000..0572a2f
--- /dev/null
+++ b/asynceapi/_models.py
@@ -0,0 +1,238 @@
+# Copyright (c) 2024-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Models for the asynceapi package."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from logging import getLogger
+from typing import TYPE_CHECKING, Any, Literal
+from uuid import uuid4
+
+from ._constants import EapiCommandFormat
+from ._errors import EapiReponseError
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand, EapiTextOutput, JsonRpc
+
+LOGGER = getLogger(__name__)
+
+
+# pylint: disable=too-many-instance-attributes
+@dataclass(frozen=True)
+class EapiRequest:
+ """Model for an eAPI request.
+
+ Attributes
+ ----------
+ commands : list[EapiSimpleCommand | EapiComplexCommand]
+ A list of commands to execute.
+ version : int | Literal["latest"]
+ The eAPI version to use. Defaults to "latest".
+ format : EapiCommandFormat
+ The command output format. Defaults "json".
+ timestamps : bool
+ Include timestamps in the command output. Defaults to False.
+ auto_complete : bool
+ Enable command auto-completion. Defaults to False.
+ expand_aliases : bool
+ Expand command aliases. Defaults to False.
+ stop_on_error : bool
+ Stop command execution on first error. Defaults to True.
+ id : int | str
+ The request ID. Defaults to a random hex string.
+ """
+
+ commands: list[EapiSimpleCommand | EapiComplexCommand]
+ version: int | Literal["latest"] = "latest"
+ format: EapiCommandFormat = EapiCommandFormat.JSON
+ timestamps: bool = False
+ auto_complete: bool = False
+ expand_aliases: bool = False
+ stop_on_error: bool = True
+ id: int | str = field(default_factory=lambda: uuid4().hex)
+
+ def to_jsonrpc(self) -> JsonRpc:
+ """Return the JSON-RPC dictionary payload for the request."""
+ return {
+ "jsonrpc": "2.0",
+ "method": "runCmds",
+ "params": {
+ "version": self.version,
+ "cmds": self.commands,
+ "format": self.format,
+ "timestamps": self.timestamps,
+ "autoComplete": self.auto_complete,
+ "expandAliases": self.expand_aliases,
+ "stopOnError": self.stop_on_error,
+ },
+ "id": self.id,
+ }
+
+
+@dataclass(frozen=True)
+class EapiResponse:
+ """Model for an eAPI response.
+
+ Construct an EapiResponse from a JSON-RPC response dictionary using the `from_jsonrpc` class method.
+
+ Can be iterated over to access command results in order of execution.
+
+ Attributes
+ ----------
+ request_id : str
+ The ID of the original request this response corresponds to.
+ _results : dict[int, EapiCommandResult]
+ Dictionary mapping request command indices to their respective results.
+ error_code : int | None
+ The JSON-RPC error code, if any.
+ error_message : str | None
+ The JSON-RPC error message, if any.
+ """
+
+ request_id: str
+ _results: dict[int, EapiCommandResult] = field(default_factory=dict)
+ error_code: int | None = None
+ error_message: str | None = None
+
+ @property
+ def success(self) -> bool:
+ """Return True if the response has no errors."""
+ return self.error_code is None
+
+ @property
+ def results(self) -> list[EapiCommandResult]:
+ """Get all results as a list. Results are ordered by the command indices in the request."""
+ return list(self._results.values())
+
+ def __len__(self) -> int:
+ """Return the number of results."""
+ return len(self._results)
+
+ def __iter__(self) -> Iterator[EapiCommandResult]:
+ """Enable iteration over the results. Results are yielded in the same order as provided in the request."""
+ yield from self._results.values()
+
+ @classmethod
+ def from_jsonrpc(cls, response: dict[str, Any], request: EapiRequest, *, raise_on_error: bool = False) -> EapiResponse:
+ """Build an EapiResponse from a JSON-RPC eAPI response.
+
+ Parameters
+ ----------
+ response
+ The JSON-RPC eAPI response dictionary.
+ request
+ The corresponding EapiRequest.
+ raise_on_error
+ Raise an EapiReponseError if the response contains errors, by default False.
+
+ Returns
+ -------
+ EapiResponse
+ The EapiResponse object.
+ """
+ has_error = "error" in response
+ response_data = response["error"]["data"] if has_error else response["result"]
+
+ # Handle case where we have fewer results than commands (stop_on_error=True)
+ executed_count = min(len(response_data), len(request.commands))
+
+ # Process the results we have
+ results = {}
+ for i in range(executed_count):
+ cmd = request.commands[i]
+ cmd_str = cmd["cmd"] if isinstance(cmd, dict) else cmd
+ data = response_data[i]
+
+ output = None
+ errors = []
+ success = True
+ start_time = None
+ duration = None
+
+ # Parse the output based on the data type, no output when errors are present
+ if isinstance(data, dict):
+ if "errors" in data:
+ errors = data["errors"]
+ success = False
+ else:
+ output = data["output"] if request.format == EapiCommandFormat.TEXT and "output" in data else data
+
+ # Add timestamps if available
+ if request.timestamps and "_meta" in data:
+ meta = data.pop("_meta")
+ start_time = meta.get("execStartTime")
+ duration = meta.get("execDuration")
+
+ elif isinstance(data, str):
+ # Handle case where eAPI returns a JSON string response (serialized JSON) for certain commands
+ try:
+ from json import JSONDecodeError, loads
+
+ output = loads(data)
+ except (JSONDecodeError, TypeError):
+ # If it's not valid JSON, store as is
+ LOGGER.warning("Invalid JSON response for command: %s. Storing as text: %s", cmd_str, data)
+ output = data
+
+ results[i] = EapiCommandResult(
+ command=cmd_str,
+ output=output,
+ errors=errors,
+ success=success,
+ start_time=start_time,
+ duration=duration,
+ )
+
+ # If stop_on_error is True and we have an error, indicate commands not executed
+ if has_error and request.stop_on_error and executed_count < len(request.commands):
+ for i in range(executed_count, len(request.commands)):
+ cmd = request.commands[i]
+ cmd_str = cmd["cmd"] if isinstance(cmd, dict) else cmd
+ results[i] = EapiCommandResult(command=cmd_str, output=None, errors=["Command not executed due to previous error"], success=False, executed=False)
+
+ response_obj = cls(
+ request_id=response["id"],
+ _results=results,
+ error_code=response["error"]["code"] if has_error else None,
+ error_message=response["error"]["message"] if has_error else None,
+ )
+
+ if raise_on_error and has_error:
+ raise EapiReponseError(response_obj)
+
+ return response_obj
+
+
+@dataclass(frozen=True)
+class EapiCommandResult:
+ """Model for an eAPI command result.
+
+ Attributes
+ ----------
+ command : str
+ The command that was executed.
+ output : EapiJsonOutput | EapiTextOutput | None
+ The command result output. None if the command returned errors.
+ errors : list[str]
+ A list of error messages, if any.
+ success : bool
+ True if the command was successful.
+ executed : bool
+ True if the command was executed. When `stop_on_error` is True in the request, some commands may not be executed.
+ start_time : float | None
+ Command execution start time in seconds. Uses Unix epoch format. `timestamps` must be True in the request.
+ duration : float | None
+ Command execution duration in seconds. `timestamps` must be True in the request.
+ """
+
+ command: str
+ output: EapiJsonOutput | EapiTextOutput | None
+ errors: list[str] = field(default_factory=list)
+ success: bool = True
+ executed: bool = True
+ start_time: float | None = None
+ duration: float | None = None
diff --git a/asynceapi/_types.py b/asynceapi/_types.py
new file mode 100644
index 0000000..ebebf04
--- /dev/null
+++ b/asynceapi/_types.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2024-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Type definitions used for the asynceapi package."""
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Any, Literal
+
+if TYPE_CHECKING:
+ from ._constants import EapiCommandFormat
+
+if sys.version_info >= (3, 11):
+ from typing import NotRequired, TypedDict
+else:
+ from typing_extensions import NotRequired, TypedDict
+
+EapiJsonOutput = dict[str, Any]
+"""Type definition of an eAPI JSON output response."""
+EapiTextOutput = str
+"""Type definition of an eAPI text output response."""
+EapiSimpleCommand = str
+"""Type definition of an eAPI simple command. A simple command is the CLI command to run as a string."""
+
+
+class EapiComplexCommand(TypedDict):
+ """Type definition of an eAPI complex command. A complex command is a dictionary with the CLI command to run with additional parameters."""
+
+ cmd: str
+ input: NotRequired[str]
+ revision: NotRequired[int]
+
+
+class JsonRpc(TypedDict):
+ """Type definition of a JSON-RPC payload."""
+
+ jsonrpc: Literal["2.0"]
+ method: Literal["runCmds"]
+ params: JsonRpcParams
+ id: NotRequired[int | str]
+
+
+class JsonRpcParams(TypedDict):
+ """Type definition of JSON-RPC parameters."""
+
+ version: NotRequired[int | Literal["latest"]]
+ cmds: list[EapiSimpleCommand | EapiComplexCommand]
+ format: NotRequired[EapiCommandFormat]
+ autoComplete: NotRequired[bool]
+ expandAliases: NotRequired[bool]
+ timestamps: NotRequired[bool]
+ stopOnError: NotRequired[bool]
diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py
index deac043..be9a79f 100644
--- a/asynceapi/aio_portcheck.py
+++ b/asynceapi/aio_portcheck.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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
diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py
index 7f83da4..e5e1d08 100644
--- a/asynceapi/config_session.py
+++ b/asynceapi/config_session.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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
@@ -10,9 +10,10 @@
from __future__ import annotations
import re
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
if TYPE_CHECKING:
+ from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand
from .device import Device
# -----------------------------------------------------------------------------
@@ -78,7 +79,7 @@ class SessionConfig:
# Public Methods
# -------------------------------------------------------------------------
- async def status_all(self) -> dict[str, Any]:
+ async def status_all(self) -> EapiJsonOutput:
"""Get the status of all the session config on the device.
Run the following command on the device:
@@ -86,7 +87,7 @@ class SessionConfig:
Returns
-------
- dict[str, Any]
+ EapiJsonOutput
Dictionary of native EOS eAPI response; see `status` method for
details.
@@ -116,9 +117,9 @@ class SessionConfig:
}
```
"""
- return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any]
+ return await self._cli(command="show configuration sessions detail")
- async def status(self) -> dict[str, Any] | None:
+ async def status(self) -> EapiJsonOutput | None:
"""Get the status of a session config on the device.
Run the following command on the device:
@@ -129,7 +130,7 @@ class SessionConfig:
Returns
-------
- dict[str, Any] | None
+ EapiJsonOutput | None
Dictionary instance of the session status. If the session does not exist,
then this method will return None.
@@ -201,7 +202,7 @@ class SessionConfig:
# 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]
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = [self._cli_config_session]
if replace:
commands.append(self.CLI_CFG_FACTORY_RESET)
@@ -232,7 +233,7 @@ class SessionConfig:
if timer:
command += f" timer {timer}"
- await self._cli(command)
+ await self._cli(command=command)
async def abort(self) -> None:
"""Abort the configuration session.
@@ -240,7 +241,7 @@ class SessionConfig:
Run the following command on the device:
# configure session abort
"""
- await self._cli(f"{self._cli_config_session} abort")
+ await self._cli(command=f"{self._cli_config_session} abort")
async def diff(self) -> str:
"""Return the "diff" of the session config relative to the running config.
@@ -257,7 +258,7 @@ class SessionConfig:
----------
* 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
+ return await self._cli(command=f"show session-config named {self.name} diffs", ofmt="text")
async def load_file(self, filename: str, *, replace: bool = False) -> None:
"""Load the configuration from into the session configuration.
@@ -281,12 +282,12 @@ class SessionConfig:
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]
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = [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]]
+ res = await self._cli(commands=commands)
checks_re = re.compile(r"error|abort|invalid", flags=re.IGNORECASE)
messages = res[-1]["messages"]
@@ -295,4 +296,4 @@ class SessionConfig:
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")
+ await self._cli(command="write")
diff --git a/asynceapi/device.py b/asynceapi/device.py
index c423c36..a7702da 100644
--- a/asynceapi/device.py
+++ b/asynceapi/device.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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
@@ -10,7 +10,7 @@
from __future__ import annotations
from socket import getservbyname
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Literal, overload
# -----------------------------------------------------------------------------
# Public Imports
@@ -20,12 +20,13 @@ import httpx
# -----------------------------------------------------------------------------
# Private Imports
# -----------------------------------------------------------------------------
+from ._constants import EapiCommandFormat
from .aio_portcheck import port_check_url
from .config_session import SessionConfig
from .errors import EapiCommandError
if TYPE_CHECKING:
- from collections.abc import Sequence
+ from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand, EapiTextOutput, JsonRpc
# -----------------------------------------------------------------------------
# Exports
@@ -121,18 +122,139 @@ class Device(httpx.AsyncClient):
"""
return await port_check_url(self.base_url)
+ # Single command, JSON output, no suppression
+ @overload
async def cli(
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",
+ *,
+ command: EapiSimpleCommand | EapiComplexCommand,
+ commands: None = None,
+ ofmt: Literal["json"] = "json",
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[False] = False,
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> EapiJsonOutput: ...
+
+ # Multiple commands, JSON output, no suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: None = None,
+ commands: list[EapiSimpleCommand | EapiComplexCommand],
+ ofmt: Literal["json"] = "json",
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[False] = False,
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> list[EapiJsonOutput]: ...
+
+ # Single command, TEXT output, no suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: EapiSimpleCommand | EapiComplexCommand,
+ commands: None = None,
+ ofmt: Literal["text"],
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[False] = False,
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> EapiTextOutput: ...
+
+ # Multiple commands, TEXT output, no suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: None = None,
+ commands: list[EapiSimpleCommand | EapiComplexCommand],
+ ofmt: Literal["text"],
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[False] = False,
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> list[EapiTextOutput]: ...
+
+ # Single command, JSON output, with suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: EapiSimpleCommand | EapiComplexCommand,
+ commands: None = None,
+ ofmt: Literal["json"] = "json",
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[True],
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> EapiJsonOutput | None: ...
+
+ # Multiple commands, JSON output, with suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: None = None,
+ commands: list[EapiSimpleCommand | EapiComplexCommand],
+ ofmt: Literal["json"] = "json",
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[True],
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> list[EapiJsonOutput] | None: ...
+
+ # Single command, TEXT output, with suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: EapiSimpleCommand | EapiComplexCommand,
+ commands: None = None,
+ ofmt: Literal["text"],
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[True],
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> EapiTextOutput | None: ...
+
+ # Multiple commands, TEXT output, with suppression
+ @overload
+ async def cli(
+ self,
+ *,
+ command: None = None,
+ commands: list[EapiSimpleCommand | EapiComplexCommand],
+ ofmt: Literal["text"],
+ version: int | Literal["latest"] = "latest",
+ suppress_error: Literal[True],
+ auto_complete: bool = False,
+ expand_aliases: bool = False,
+ req_id: int | str | None = None,
+ ) -> list[EapiTextOutput] | None: ...
+
+ # Actual implementation
+ async def cli(
+ self,
+ command: EapiSimpleCommand | EapiComplexCommand | None = None,
+ commands: list[EapiSimpleCommand | EapiComplexCommand] | None = None,
+ ofmt: Literal["json", "text"] = "json",
+ version: int | Literal["latest"] = "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:
+ ) -> EapiJsonOutput | EapiTextOutput | list[EapiJsonOutput] | list[EapiTextOutput] | None:
"""Execute one or more CLI commands.
Parameters
@@ -143,6 +265,7 @@ class Device(httpx.AsyncClient):
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.
+ eAPI defaults to 'json'.
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
@@ -158,13 +281,13 @@ class Device(httpx.AsyncClient):
response = dev.cli(..., suppress_error=True)
auto_complete
- Enabled/disables the command auto-compelete feature of the EAPI. Per the
+ 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
+ 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.
@@ -176,15 +299,34 @@ class Device(httpx.AsyncClient):
Returns
-------
- list[dict[str, Any] | str] | dict[str, Any] | str | None
- One or List of output responses, per the description above.
+ dict[str, Any]
+ Single command, JSON output, suppress_error=False
+ list[dict[str, Any]]
+ Multiple commands, JSON output, suppress_error=False
+ str
+ Single command, TEXT output, suppress_error=False
+ list[str]
+ Multiple commands, TEXT output, suppress_error=False
+ dict[str, Any] | None
+ Single command, JSON output, suppress_error=True
+ list[dict[str, Any]] | None
+ Multiple commands, JSON output, suppress_error=True
+ str | None
+ Single command, TEXT output, suppress_error=True
+ list[str] | None
+ Multiple commands, TEXT output, suppress_error=True
"""
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
+ commands=[command] if command else commands if commands else [],
+ ofmt=ofmt,
+ version=version,
+ auto_complete=auto_complete,
+ expand_aliases=expand_aliases,
+ req_id=req_id,
)
try:
@@ -197,14 +339,14 @@ class Device(httpx.AsyncClient):
def _jsonrpc_command(
self,
- commands: Sequence[str | dict[str, Any]] | None = None,
- ofmt: str | None = None,
- version: int | str | None = "latest",
+ commands: list[EapiSimpleCommand | EapiComplexCommand],
+ ofmt: Literal["json", "text"] = "json",
+ version: int | Literal["latest"] = "latest",
*,
auto_complete: bool = False,
expand_aliases: bool = False,
req_id: int | str | None = None,
- ) -> dict[str, Any]:
+ ) -> JsonRpc:
"""Create the JSON-RPC command dictionary object.
Parameters
@@ -213,6 +355,7 @@ class Device(httpx.AsyncClient):
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.
+ eAPI defaults to 'json'.
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
@@ -241,25 +384,20 @@ class Device(httpx.AsyncClient):
dict containing the JSON payload to run the command.
"""
- cmd: dict[str, Any] = {
+ return {
"jsonrpc": "2.0",
"method": "runCmds",
"params": {
"version": version,
"cmds": commands,
- "format": ofmt or self.EAPI_DEFAULT_OFMT,
+ "format": EapiCommandFormat(ofmt),
+ "autoComplete": auto_complete,
+ "expandAliases": expand_aliases,
},
"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]:
+ async def jsonrpc_exec(self, jsonrpc: JsonRpc) -> list[EapiJsonOutput] | list[EapiTextOutput]:
"""Execute the JSON-RPC dictionary object.
Parameters
@@ -315,7 +453,7 @@ class Device(httpx.AsyncClient):
failed_cmd = commands[err_at]
raise EapiCommandError(
- passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])],
+ passed=[get_output(cmd_data[i]) for i in range(err_at)],
failed=failed_cmd["cmd"] if isinstance(failed_cmd, dict) else failed_cmd,
errors=cmd_data[err_at]["errors"],
errmsg=err_msg,
diff --git a/asynceapi/errors.py b/asynceapi/errors.py
index 5fce9db..50b02c6 100644
--- a/asynceapi/errors.py
+++ b/asynceapi/errors.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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
@@ -6,13 +6,16 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING
import httpx
+if TYPE_CHECKING:
+ from ._types import EapiComplexCommand, EapiJsonOutput, EapiSimpleCommand, EapiTextOutput
+
class EapiCommandError(RuntimeError):
- """Exception class for EAPI command errors.
+ """Exception class for eAPI command errors.
Attributes
----------
@@ -23,7 +26,14 @@ class EapiCommandError(RuntimeError):
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:
+ def __init__(
+ self,
+ failed: str,
+ errors: list[str],
+ errmsg: str,
+ passed: list[EapiJsonOutput] | list[EapiTextOutput],
+ not_exec: list[EapiSimpleCommand | EapiComplexCommand],
+ ) -> None:
"""Initialize for the EapiCommandError exception."""
self.failed = failed
self.errmsg = errmsg
diff --git a/docs/README.md b/docs/README.md
index 07ac3d2..b6e00c7 100755
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,5 +1,5 @@
diff --git a/docs/advanced_usages/as-python-lib.md b/docs/advanced_usages/as-python-lib.md
index fce5e7e..1a7dedb 100644
--- a/docs/advanced_usages/as-python-lib.md
+++ b/docs/advanced_usages/as-python-lib.md
@@ -1,5 +1,5 @@
@@ -14,8 +14,8 @@ ANTA is a Python library that can be used in user applications. This section des
A device is represented in ANTA as a instance of a subclass of the [AntaDevice](../api/device.md#anta.device.AntaDevice) abstract class.
There are few abstract methods that needs to be implemented by child classes:
-- The [collect()](../api/device.md#anta.device.AntaDevice.collect) coroutine is in charge of collecting outputs of [AntaCommand](../api/models.md#anta.models.AntaCommand) instances.
-- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md#anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/models.md#anta.models.AntaTest) to skip devices based on their hardware models.
+- The [collect()](../api/device.md#anta.device.AntaDevice.collect) coroutine is in charge of collecting outputs of [AntaCommand](../api/commands.md#anta.models.AntaCommand) instances.
+- The [refresh()](../api/device.md#anta.device.AntaDevice.refresh) coroutine is in charge of updating attributes of the [AntaDevice](../api/device.md#anta.device.AntaDevice) instance. These attributes are used by [AntaInventory](../api/inventory.md#anta.inventory.AntaInventory) to filter out unreachable devices or by [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) to skip devices based on their hardware models.
The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to copy files to and from the device. It does not need to be implemented if tests are not using it.
@@ -24,7 +24,7 @@ The [copy()](../api/device.md#anta.device.AntaDevice.copy) coroutine is used to
The [AsyncEOSDevice](../api/device.md#anta.device.AsyncEOSDevice) class is an implementation of [AntaDevice](../api/device.md#anta.device.AntaDevice) for Arista EOS.
It uses the [aio-eapi](https://github.com/jeremyschulman/aio-eapi) eAPI client and the [AsyncSSH](https://github.com/ronf/asyncssh) library.
-- The [_collect()](../api/device.md#anta.device.AsyncEOSDevice._collect) coroutine collects [AntaCommand](../api/models.md#anta.models.AntaCommand) outputs using eAPI.
+- The [\_collect()](../api/device.md#anta.device.AsyncEOSDevice._collect) coroutine collects [AntaCommand](../api/models.md#anta.models.AntaCommand) outputs using eAPI.
- The [refresh()](../api/device.md#anta.device.AsyncEOSDevice.refresh) coroutine tries to open a TCP connection on the eAPI port and update the `is_online` attribute accordingly. If the TCP connection succeeds, it sends a `show version` command to gather the hardware model of the device and updates the `established` and `hw_model` attributes.
- The [copy()](../api/device.md#anta.device.AsyncEOSDevice.copy) coroutine copies files to and from the device using the SCP protocol.
diff --git a/docs/advanced_usages/caching.md b/docs/advanced_usages/caching.md
index 8b089ce..628376b 100644
--- a/docs/advanced_usages/caching.md
+++ b/docs/advanced_usages/caching.md
@@ -1,5 +1,5 @@
@@ -8,30 +8,17 @@ ANTA is a streamlined Python framework designed for efficient interaction with n
## Configuration
-By default, ANTA utilizes [aiocache](https://github.com/aio-libs/aiocache)'s memory cache backend, also called [`SimpleMemoryCache`](https://aiocache.aio-libs.org/en/v0.12.2/caches.html#simplememorycache). This library aims for simplicity and supports asynchronous operations to go along with Python `asyncio` used in ANTA.
-
The `_init_cache()` method of the [AntaDevice](../api/device.md#anta.device.AntaDevice) abstract class initializes the cache. Child classes can override this method to tweak the cache configuration:
-```python
-def _init_cache(self) -> None:
- """
- Initialize cache for the device, can be overridden by subclasses to manipulate how it works
- """
- self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
- self.cache_locks = defaultdict(asyncio.Lock)
-```
-
-The cache is also configured with `aiocache`'s [`HitMissRatioPlugin`](https://aiocache.aio-libs.org/en/v0.12.2/plugins.html#hitmissratioplugin) plugin to calculate the ratio of hits the cache has and give useful statistics for logging purposes in ANTA.
-
## Cache key design
The cache is initialized per `AntaDevice` and uses the following cache key design:
`:`
-The `uid` is an attribute of [AntaCommand](../api/models.md#anta.models.AntaCommand), which is a unique identifier generated from the command, version, revision and output format.
+The `uid` is an attribute of [AntaCommand](../api/commands.md#anta.models.AntaCommand), which is a unique identifier generated from the command, version, revision and output format.
-Each UID has its own asyncio lock. This design allows coroutines that need to access the cache for different UIDs to do so concurrently. The locks are managed by the `self.cache_locks` dictionary.
+Each UID has its own asyncio lock. This design allows coroutines that need to access the cache for different UIDs to do so concurrently. The locks are managed by the `AntaCache.locks` dictionary.
## Mechanisms
@@ -45,37 +32,37 @@ There might be scenarios where caching is not wanted. You can disable caching in
1. Caching can be disabled globally, for **ALL** commands on **ALL** devices, using the `--disable-cache` global flag when invoking anta at the [CLI](../cli/overview.md#invoking-anta-cli):
- ```bash
- anta --disable-cache --username arista --password arista nrfu table
- ```
+ ```bash
+ anta --disable-cache --username arista --password arista nrfu table
+ ```
2. Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` when defining the ANTA [Inventory](../usage-inventory-catalog.md#device-inventory) file:
- ```yaml
- anta_inventory:
- hosts:
- - host: 172.20.20.101
- name: DC1-SPINE1
- tags: ["SPINE", "DC1"]
- disable_cache: True # Set this key to True
- - host: 172.20.20.102
- name: DC1-SPINE2
- tags: ["SPINE", "DC1"]
- disable_cache: False # Optional since it's the default
+ ```yaml
+ anta_inventory:
+ hosts:
+ - host: 172.20.20.101
+ name: DC1-SPINE1
+ tags: ["SPINE", "DC1"]
+ disable_cache: True # Set this key to True
+ - host: 172.20.20.102
+ name: DC1-SPINE2
+ tags: ["SPINE", "DC1"]
+ disable_cache: False # Optional since it's the default
- networks:
- - network: "172.21.21.0/24"
- disable_cache: True
+ networks:
+ - network: "172.21.21.0/24"
+ disable_cache: True
- ranges:
- - start: 172.22.22.10
- end: 172.22.22.19
- disable_cache: True
- ```
+ ranges:
+ - start: 172.22.22.10
+ end: 172.22.22.19
+ disable_cache: True
+ ```
- This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key.
+ This approach effectively disables caching for **ALL** commands sent to devices targeted by the `disable_cache` key.
-3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../api/models.md#anta.models.AntaCommand) or [`AntaTemplate`](../api/models.md#anta.models.AntaTemplate) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching.
+3. For tests developers, caching can be disabled for a specific [`AntaCommand`](../api/commands.md#anta.models.AntaCommand) or [`AntaTemplate`](../api/commands.md#anta.models.AntaTemplate) by setting the `use_cache` attribute to `False`. That means the command output will always be collected on the device and therefore, never use caching.
### Disable caching in a child class of `AntaDevice`
diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md
index 2fc61cc..0880743 100644
--- a/docs/advanced_usages/custom-tests.md
+++ b/docs/advanced_usages/custom-tests.md
@@ -1,5 +1,5 @@
@@ -13,7 +13,7 @@ ANTA is not only a Python library with a CLI and a collection of built-in tests,
A test is a Python class where a test function is defined and will be run by the framework.
-ANTA provides an abstract class [AntaTest](../api/models.md#anta.models.AntaTest). This class does the heavy lifting and provide the logic to define, collect and test data. The code below is an example of a simple test in ANTA, which is an [AntaTest](../api/models.md#anta.models.AntaTest) subclass:
+ANTA provides an abstract class [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest). This class does the heavy lifting and provide the logic to define, collect and test data. The code below is an example of a simple test in ANTA, which is an [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass:
````python
from anta.models import AntaTest, AntaCommand
@@ -51,18 +51,18 @@ class VerifyTemperature(AntaTest):
self.result.is_failure(f"Device temperature exceeds acceptable limits. Current system status: '{temperature_status}'")
````
-[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/tests/anta_test.md#anta.models.AntaTest) also provide more advanced capabilities like [AntaCommand](../api/commands.md#anta.models.AntaCommand) templating using the [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) class or test inputs definition and validation using [AntaTest.Input](../api/tests/anta_test.md#anta.models.AntaTest.Input) [pydantic](https://docs.pydantic.dev/latest/) model. This will be discussed in the sections below.
## AntaTest structure
-Full AntaTest API documentation is available in the [API documentation section](../api/models.md#anta.models.AntaTest)
+Full AntaTest API documentation is available in the [API documentation section](../api/tests/anta_test.md#anta.models.AntaTest)
### Class Attributes
- `name` (`str`, `optional`): Name of the test. Used during reporting. By default set to the Class name.
- `description` (`str`, `optional`): A human readable description of your test. By default set to the first line of the docstring.
- `categories` (`list[str]`): A list of categories in which the test belongs.
-- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list **must** be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later.
+- `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list **must** be a list of [AntaCommand](../api/commands.md#anta.models.AntaCommand) or [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances will be discussed later.
> [!INFO]
> All these class attributes are mandatory. If any attribute is missing, a `NotImplementedError` exception will be raised during class instantiation.
@@ -86,7 +86,7 @@ Full AntaTest API documentation is available in the [API documentation section](
>
> - **Logger object**
>
-> ANTA already provides comprehensive logging at every steps of a test execution. The [AntaTest](../api/models.md#anta.models.AntaTest) class also provides a `logger` attribute that is a Python logger specific to the test instance. See [Python documentation](https://docs.python.org/3/library/logging.html) for more information.
+> ANTA already provides comprehensive logging at every steps of a test execution. The [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) class also provides a `logger` attribute that is a Python logger specific to the test instance. See [Python documentation](https://docs.python.org/3/library/logging.html) for more information.
>
> - **AntaDevice object**
>
@@ -94,13 +94,13 @@ Full AntaTest API documentation is available in the [API documentation section](
### Test Inputs
-[AntaTest.Input](../api/models.md#anta.models.AntaTest.Input) is a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that allow test developers to define their test inputs. [pydantic](https://docs.pydantic.dev/latest/) provides out of the box [error handling](https://docs.pydantic.dev/latest/usage/models/#error-handling) for test input validation based on the type hints defined by the test developer.
+[AntaTest.Input](../api/tests/anta_test.md#anta.models.AntaTest.Input) is a [pydantic model](https://docs.pydantic.dev/latest/usage/models/) that allow test developers to define their test inputs. [pydantic](https://docs.pydantic.dev/latest/) provides out of the box [error handling](https://docs.pydantic.dev/latest/usage/models/#error-handling) for test input validation based on the type hints defined by the test developer.
-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/tests/anta_test.md#anta.models.AntaTest.Input) provides common test inputs for all [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) instances:
#### Input model
-Full `Input` model documentation is available in [API documentation section](../api/models.md#anta.models.AntaTest.Input)
+Full `Input` model documentation is available in [API documentation section](../api/tests/anta_test.md#anta.models.AntaTest.Input)
::: anta.models.AntaTest.Input
options:
@@ -118,7 +118,7 @@ Full `Input` model documentation is available in [API documentation section](../
#### ResultOverwrite model
-Full `ResultOverwrite` model documentation is available in [API documentation section](../api/models.md#anta.models.AntaTest.Input.ResultOverwrite)
+Full `ResultOverwrite` model documentation is available in [API documentation section](../api/tests/anta_test.md#anta.models.AntaTest.Input.ResultOverwrite)
::: anta.models.AntaTest.Input.ResultOverwrite
options:
@@ -138,31 +138,31 @@ Full `ResultOverwrite` model documentation is available in [API documentation se
### Methods
-- [test(self) -> None](../api/models.md#anta.models.AntaTest.test): This is an abstract method that **must** be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and **must** set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method.
-- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/models.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/models.md#anta.models.AntaTemplate) occurrence and **must** return a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/models.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute.
+- [test(self) -> None](../api/tests/anta_test.md#anta.models.AntaTest.test): This is an abstract method that **must** be implemented. It contains the test logic that can access the collected command outputs using the `instance_commands` instance attribute, access the test inputs using the `inputs` instance attribute and **must** set the `result` instance attribute accordingly. It must be implemented using the `AntaTest.anta_test` decorator that provides logging and will collect commands before executing the `test()` method.
+- [render(self, template: AntaTemplate) -> list[AntaCommand]](../api/tests/anta_test.md#anta.models.AntaTest.render): This method only needs to be implemented if [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances are present in the `commands` class attribute. It will be called for every [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) occurrence and **must** return a list of [AntaCommand](../api/commands.md#anta.models.AntaCommand) using the [AntaTemplate.render()](../api/commands.md#anta.models.AntaTemplate.render) method. It can access test inputs using the `inputs` instance attribute.
## Test execution
Below is a high level description of the test execution flow in ANTA:
-1. ANTA will parse the test catalog to get the list of [AntaTest](../api/models.md#anta.models.AntaTest) subclasses to instantiate and their associated input values. We consider a single [AntaTest](../api/models.md#anta.models.AntaTest) subclass in the following steps.
+1. ANTA will parse the test catalog to get the list of [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclasses to instantiate and their associated input values. We consider a single [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass in the following steps.
-2. ANTA will instantiate the [AntaTest](../api/models.md#anta.models.AntaTest) subclass and a single device will be provided to the test instance. The `Input` model defined in the class will also be instantiated at this moment. If any [ValidationError](https://docs.pydantic.dev/latest/errors/errors/) is raised, the test execution will be stopped.
+2. ANTA will instantiate the [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass and a single device will be provided to the test instance. The `Input` model defined in the class will also be instantiated at this moment. If any [ValidationError](https://docs.pydantic.dev/latest/errors/errors/) is raised, the test execution will be stopped.
-3. If there is any [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instance in the `commands` class attribute, [render()](../api/models.md#anta.models.AntaTest.render) will be called for every occurrence. At this moment, the `instance_commands` attribute has been initialized. If any rendering error occurs, the test execution will be stopped.
+3. If there is any [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instance in the `commands` class attribute, [render()](../api/tests/anta_test.md#anta.models.AntaTest.render) will be called for every occurrence. At this moment, the `instance_commands` attribute has been initialized. If any rendering error occurs, the test execution will be stopped.
4. The `AntaTest.anta_test` decorator will collect the commands from the device and update the `instance_commands` attribute with the outputs. If any collection error occurs, the test execution will be stopped.
-5. The [test()](../api/models.md#anta.models.AntaTest.test) method is executed.
+5. The [test()](../api/tests/anta_test.md#anta.models.AntaTest.test) method is executed.
## Writing an AntaTest subclass
-In this section, we will go into all the details of writing an [AntaTest](../api/models.md#anta.models.AntaTest) subclass.
+In this section, we will go into all the details of writing an [AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) subclass.
### Class definition
-Import [anta.models.AntaTest](../api/models.md#anta.models.AntaTest) and define your own class.
-Define the mandatory class attributes using [anta.models.AntaCommand](../api/models.md#anta.models.AntaCommand), [anta.models.AntaTemplate](../api/models.md#anta.models.AntaTemplate) or both.
+Import [anta.models.AntaTest](../api/tests/anta_test.md#anta.models.AntaTest) and define your own class.
+Define the mandatory class attributes using [anta.models.AntaCommand](../api/commands.md#anta.models.AntaCommand), [anta.models.AntaTemplate](../api/commands.md#anta.models.AntaTemplate) or both.
> [!NOTE]
> Caching can be disabled per `AntaCommand` or `AntaTemplate` by setting the `use_cache` argument to `False`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md).
@@ -244,7 +244,7 @@ class (AntaTest):
```
To define an input field type, refer to the [pydantic documentation](https://docs.pydantic.dev/latest/usage/types/types/) about types.
-You can also leverage [anta.custom_types](../api/types.md) that provides reusable types defined in ANTA tests.
+You can also leverage [anta.custom_types](../api/tests/types.md) that provides reusable types defined in ANTA tests.
Regarding required, optional and nullable fields, refer to this [documentation](https://docs.pydantic.dev/latest/migration/#required-optional-and-nullable-fields) on how to define them.
@@ -253,7 +253,7 @@ Regarding required, optional and nullable fields, refer to this [documentation](
### Template rendering
-Define the `render()` method if you have [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances in your `commands` class attribute:
+Define the `render()` method if you have [AntaTemplate](../api/commands.md#anta.models.AntaTemplate) instances in your `commands` class attribute:
```python
class (AntaTest):
@@ -262,7 +262,7 @@ class (AntaTest):
return [template.render(=input_value) for input_value in self.inputs.]
```
-You can access test inputs and render as many [AntaCommand](../api/models.md#anta.models.AntaCommand) as desired.
+You can access test inputs and render as many [AntaCommand](../api/commands.md#anta.models.AntaCommand) as desired.
### Test definition
@@ -282,7 +282,7 @@ The logic usually includes the following different stages:
2. If needed, access the test inputs using the `self.inputs` instance attribute and write your conditional logic.
3. Set the `result` instance attribute to reflect the test result by either calling `self.result.is_success()` or `self.result.is_failure("")`. Sometimes, setting the test result to `skipped` using `self.result.is_skipped("")` can make sense (e.g. testing the OSPF neighbor states but no neighbor was found). However, you should not need to catch any exception and set the test result to `error` since the error handling is done by the framework, see below.
-The example below is based on the [VerifyTemperature](../api/tests.hardware.md#anta.tests.hardware.VerifyTemperature) test.
+The example below is based on the [VerifyTemperature](../api/tests/hardware.md#anta.tests.hardware.VerifyTemperature) test.
```python
class VerifyTemperature(AntaTest):
diff --git a/docs/api/catalog.md b/docs/api/catalog.md
index 44cc4df..0aca63d 100644
--- a/docs/api/catalog.md
+++ b/docs/api/catalog.md
@@ -1,14 +1,14 @@
+---
+anta_title: ANTA Catalog API
+---
-### ::: anta.catalog.AntaCatalog
+::: anta.catalog.AntaCatalog
- options:
- filters: ["!^_[^_]", "!__str__"]
+::: anta.catalog.AntaTestDefinition
-### ::: anta.catalog.AntaTestDefinition
-
-### ::: anta.catalog.AntaCatalogFile
+::: anta.catalog.AntaCatalogFile
diff --git a/docs/api/class-diagram.md b/docs/api/class-diagram.md
new file mode 100644
index 0000000..1410791
--- /dev/null
+++ b/docs/api/class-diagram.md
@@ -0,0 +1,15 @@
+---
+anta_title: ANTA Class Diagram
+---
+
+
+!!! info
+ The classes colored in pink :fontawesome-solid-square-full:{ .pydantic_pink } are [:simple-pydantic:{ .pydantic_pink } pydantic models](https://docs.pydantic.dev/latest/concepts/models/).
+
+``` mermaid
+--8<-- "api/class-diagram.mmd"
+```
diff --git a/docs/api/class-diagram.mmd b/docs/api/class-diagram.mmd
new file mode 100644
index 0000000..45b0fe9
--- /dev/null
+++ b/docs/api/class-diagram.mmd
@@ -0,0 +1,189 @@
+classDiagram
+ class AntaDevice {
+ <>
+ name : str
+ tags : Optional[set[str]]
+ hw_model : str | None
+ established : bool
+ is_online : bool
+ cache_statistics : dict[str, Any]
+ collect(command: AntaCommand) None
+ collect_commands(commands: list[AntaCommand]) None
+ copy(sources: list[Path], destination: Path, direction: Literal['to', 'from']) None
+ refresh()* None
+ _collect(command: AntaCommand)* None
+ }
+ class AntaTest {
+ <>
+ name : str$
+ description : str$
+ categories : list[str]$
+ commands : list[AntaTemplate | AntaCommand]$
+ device : AntaDevice
+ inputs : Input
+ result : TestResult
+ instance_commands : list[AntaCommand]
+ failed_commands : list[AntaCommand]
+ collected : bool
+ blocked : bool
+ module : str
+ logger : Logger
+ anta_test(function: F) Callable[..., Coroutine[Any, Any, TestResult]]$
+ save_commands_data(eos_data: list[dict[str, Any] | str]) None
+ render(template: AntaTemplate) list[AntaCommand]
+ test() None*
+ }
+ class AntaCommand:::pydantic {
+ command : str
+ version : Literal[1, 'latest']
+ revision : Revision | None
+ ofmt : Literal['json', 'text']
+ output : dict[str, Any] | str | None
+ json_output : dict[str, Any]
+ text_output : str
+ uid : str
+ template : AntaTemplate | None
+ params : AntaParamsBaseModel
+ errors : list[str]
+ error : bool
+ use_cache : bool
+ collected : bool
+ requires_privileges : bool
+ returned_known_eos_error : bool
+ supported : bool
+ }
+ class AntaTemplate {
+ template : str
+ version : Literal[1, 'latest']
+ revision : Revision | None
+ ofmt : Literal['json', 'text']
+ use_cache : bool
+ render() AntaCommand
+ }
+ class AntaTestStatus {
+ <>
+ UNSET
+ SUCCESS
+ FAILURE
+ ERROR
+ SKIPPED
+ }
+ class Input:::pydantic {
+ filters : Filters | None
+ result_overwrite : ResultOverwrite | None
+ }
+ class ResultManager {
+ results : list[TestResult]
+ status: AntaTestStatus
+ error_status : bool
+ results_by_status: dict[AntaTestStatus, list[TestResult]]
+ sorted_category_stats: dict[str, CategoryStats]
+ dump: list[dict[str, Any]]
+ json : str
+ test_stats: dict[str, TestStats]
+ device_stats: dict[str, DeviceStats]
+ category_stats: dict[str, CategoryStats]
+ add(result: TestResult) None
+ filter(hide: set[AntaTestStatus]) ResultManager
+ filter_by_devices(devices: set[str]) ResultManager
+ filter_by_tests(tests: set[str]) ResultManager
+ get_results(status: set[AntaTestStatus] | None, sort_by: list[str] | None) list[TestResult]
+ get_total_results(status: set[AntaTestStatus] | None) int
+ get_status() str
+ get_tests() set[str]
+ get_devices() set[str]
+ reset() None
+ }
+ class AsyncEOSDevice {
+ enable : bool
+ copy(sources: list[Path], destination: Path, direction: Literal['to', 'from']) None
+ refresh() None
+ _collect(command: AntaCommand) None
+ }
+ class TestResult:::pydantic {
+ name : str
+ test : str
+ categories : list[str]
+ description : str
+ messages : list[str]
+ result : AntaTestStatus
+ custom_field : str | None
+ is_error(message: str | None) None
+ is_failure(message: str | None) None
+ is_skipped(message: str | None) None
+ is_success(message: str | None) None
+ }
+class AntaCatalog {
+ tests : list[AntaTestDefinition]
+ filename: Path | None
+ indexes_built : bool
+ tag_to_tests : defaultdict[str | None, set[AntaTestDefinition]]
+ parse(filename: str | Path, file_format: Literal['yaml', 'json']) AntaCatalog$
+ from_dict(data: RawCatalogInput, filename: str | Path | None) AntaCatalog$
+ from_list(data: ListAntaTestTuples) AntaCatalog$
+ build_indexes(filtered_tests: set[str] | None) None
+ clear_indexes() None
+ get_tests_by_tags(tags: set[str]) set[AntaTestDefinition]
+ merge_catalogs(catalogs: list[AntaCatalog]) AntaCatalog
+ dump() AntaCatalogFile
+ }
+ class AntaCatalogFile:::pydantic {
+ root : dict[ImportString[Any], list[AntaTestDefinition]]
+ yaml() str
+ }
+ class AntaTestDefinition:::pydantic {
+ inputs : Input
+ test : type[AntaTest]
+ check_inputs() Self
+ instantiate_inputs(data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) AntaTest.Input
+ serialize_model() dict[str, AntaTest.Input]
+ }
+ class AntaInventory {
+ devices : list[AntaDevice]
+ parse(filename: str | Path, username: str, password: str, enable_password: str | None, timeout: float | None) AntaInventory$
+ add_device(device: AntaDevice) None
+ connect_inventory() None
+ get_inventory() AntaInventory
+ }
+ class AntaInventoryHost:::pydantic {
+ disable_cache : bool
+ host : Hostname | IPvAnyAddress
+ name : str | None
+ port : Port | None
+ tags : set[str] | None
+ }
+ class AntaInventoryInput:::pydantic {
+ hosts : list[AntaInventoryHost] | None
+ networks : list[AntaInventoryNetwork] | None
+ ranges : list[AntaInventoryRange] | None
+ yaml() str
+ }
+ class AntaInventoryNetwork:::pydantic {
+ disable_cache : bool
+ network : IPvAnyNetwork, str
+ tags : set[str] | None
+ }
+ class AntaInventoryRange:::pydantic {
+ disable_cache : bool
+ end : IPvAnyAddress, str
+ start : IPvAnyAddress, str
+ tags : set[str] | None
+ }
+ AsyncEOSDevice --|> AntaDevice
+ Input --* AntaTestDefinition : inputs
+ Input --* AntaTest : inputs
+ AntaTestStatus --* ResultManager : status
+ AntaTestStatus --* TestResult : result
+ TestResult --* AntaTest : result
+ AntaDevice --o AntaTest : device
+ AntaTestDefinition --o AntaCatalog : tests
+ AntaCommand --o AntaTest : commands
+ AntaTemplate ..> AntaCommand : render()
+ AntaTemplate --o AntaTest : commands
+ AntaDevice --o AntaInventory : devices
+ AntaCatalog ..> AntaCatalogFile
+ AntaInventory ..> AntaInventoryInput
+ AntaInventoryInput ..> AntaInventoryHost
+ AntaInventoryInput ..> AntaInventoryNetwork
+ AntaInventoryInput ..> AntaInventoryRange
+ classDef pydantic fill:#D63965
diff --git a/docs/api/commands.md b/docs/api/commands.md
new file mode 100644
index 0000000..56f6285
--- /dev/null
+++ b/docs/api/commands.md
@@ -0,0 +1,29 @@
+---
+anta_title: ANTA Commands API
+---
+
+
+## ::: anta.models.AntaCommand
+
+## ::: anta.models.AntaTemplate
+
+## EOS Commands Error Handling
+
+::: anta.constants.UNSUPPORTED_PLATFORM_ERRORS
+ options:
+ heading_level: 7
+ show_labels: false
+
+::: anta.constants.EOS_BLACKLIST_CMDS
+ options:
+ heading_level: 7
+ show_labels: false
+
+::: anta.constants.KNOWN_EOS_ERRORS
+ options:
+ heading_level: 7
+ show_labels: false
diff --git a/docs/api/device.md b/docs/api/device.md
index 136fec7..1887620 100644
--- a/docs/api/device.md
+++ b/docs/api/device.md
@@ -1,25 +1,18 @@
+---
+anta_title: ANTA Device API
+---
-# AntaDevice base class
-
-
-
-## ::: anta.device.AntaDevice
-
+::: anta.device.AntaDevice
options:
- filters: ["!^_[^_]", "!__(eq|rich_repr)__", "_collect"]
-
-# Async EOS device class
-
-
+ filters: ["!^_", "_collect"]
-## ::: anta.device.AsyncEOSDevice
-
+::: anta.device.AsyncEOSDevice
options:
- filters: ["!^_[^_]", "!__(eq|rich_repr)__", "_collect"]
+ filters: ["!^_", "_collect"]
diff --git a/docs/api/inventory.md b/docs/api/inventory.md
index b826b9f..7a3dda1 100644
--- a/docs/api/inventory.md
+++ b/docs/api/inventory.md
@@ -1,12 +1,20 @@
+---
+anta_title: ANTA Inventory API
+---
-### ::: anta.inventory.AntaInventory
+::: anta.inventory.AntaInventory
- options:
- filters: ["!^_[^_]", "!__str__"]
+::: anta.inventory.models.AntaInventoryInput
-### ::: anta.inventory.exceptions
+::: anta.inventory.models.AntaInventoryHost
+
+::: anta.inventory.models.AntaInventoryNetwork
+
+::: anta.inventory.models.AntaInventoryRange
+
+::: anta.inventory.exceptions
diff --git a/docs/api/inventory.models.input.md b/docs/api/inventory.models.input.md
deleted file mode 100644
index a15c20e..0000000
--- a/docs/api/inventory.models.input.md
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-### ::: anta.inventory.models.AntaInventoryInput
-
-### ::: anta.inventory.models.AntaInventoryHost
-
-### ::: anta.inventory.models.AntaInventoryNetwork
-
-### ::: anta.inventory.models.AntaInventoryRange
diff --git a/docs/api/models.md b/docs/api/models.md
deleted file mode 100644
index 1b360de..0000000
--- a/docs/api/models.md
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-# Test definition
-
-
-
-## ::: anta.models.AntaTest
-
- options:
- filters: ["!^_[^_]", "!__init_subclass__", "!update_progress"]
-
-# Command definition
-
-
-
-## ::: anta.models.AntaCommand
-
-!!! warning
- CLI commands are protected to avoid execution of critical commands such as `reload` or `write erase`.
-
- - Reload command: `^reload\s*\w*`
- - Configure mode: `^conf\w*\s*(terminal|session)*`
- - Write: `^wr\w*\s*\w+`
-
-# Template definition
-
-
-
-## ::: anta.models.AntaTemplate
diff --git a/docs/api/csv_reporter.md b/docs/api/reporter/csv.md
similarity index 84%
rename from docs/api/csv_reporter.md
rename to docs/api/reporter/csv.md
index 0432c45..916840d 100644
--- a/docs/api/csv_reporter.md
+++ b/docs/api/reporter/csv.md
@@ -2,7 +2,7 @@
anta_title: CSV Reporter
---
diff --git a/docs/api/reporter/jinja.md b/docs/api/reporter/jinja.md
new file mode 100644
index 0000000..5b5c1d5
--- /dev/null
+++ b/docs/api/reporter/jinja.md
@@ -0,0 +1,10 @@
+---
+anta_title: Jinja Reporter
+---
+
+
+::: anta.reporter.ReportJinja
diff --git a/docs/api/md_reporter.md b/docs/api/reporter/markdown.md
similarity index 84%
rename from docs/api/md_reporter.md
rename to docs/api/reporter/markdown.md
index 7fa1a15..9d1750e 100644
--- a/docs/api/md_reporter.md
+++ b/docs/api/reporter/markdown.md
@@ -2,7 +2,7 @@
anta_title: Markdown Reporter
---
diff --git a/docs/api/reporter/table.md b/docs/api/reporter/table.md
new file mode 100644
index 0000000..4e8a0a6
--- /dev/null
+++ b/docs/api/reporter/table.md
@@ -0,0 +1,10 @@
+---
+anta_title: Table Reporter
+---
+
+
+::: anta.reporter.ReportTable
diff --git a/docs/api/reporters.md b/docs/api/reporters.md
deleted file mode 100644
index a72e107..0000000
--- a/docs/api/reporters.md
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-::: anta.reporter
- options:
- show_root_heading: false
- show_root_toc_entry: false
diff --git a/docs/api/result.md b/docs/api/result.md
new file mode 100644
index 0000000..bf64f29
--- /dev/null
+++ b/docs/api/result.md
@@ -0,0 +1,13 @@
+---
+anta_title: ANTA Result API
+---
+
+
+::: anta.result_manager.ResultManager
+ options:
+ extensions: [griffe_warnings_deprecated]
+::: anta.result_manager.models.TestResult
diff --git a/docs/api/result_manager.md b/docs/api/result_manager.md
deleted file mode 100644
index 9fc978a..0000000
--- a/docs/api/result_manager.md
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-# Result Manager definition
-
-
-
-## ::: anta.result_manager.ResultManager
-
- options:
- filters: ["!^_[^_]", "!^__len__"]
diff --git a/docs/api/result_manager_models.md b/docs/api/result_manager_models.md
deleted file mode 100644
index 42e2648..0000000
--- a/docs/api/result_manager_models.md
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-# Test Result model
-
-
-
-## ::: anta.result_manager.models.TestResult
-
- options:
- filters: ["!^_[^_]", "!__str__"]
diff --git a/docs/api/runner.md b/docs/api/runner.md
index a2de007..7942947 100644
--- a/docs/api/runner.md
+++ b/docs/api/runner.md
@@ -1,10 +1,15 @@
+---
+anta_title: ANTA Runner API
+---
+Refer to the [Getting started - Basic usage in a Python script](../getting-started.md/#basic-usage-in-a-python-script) section for a usage example.
+
### ::: anta.runner
options:
- filters: ["!^_[^_]", "!__str__"]
+ show_root_full_path: true
diff --git a/docs/api/tests.flow_tracking.md b/docs/api/tests.flow_tracking.md
deleted file mode 100644
index 0df0b1d..0000000
--- a/docs/api/tests.flow_tracking.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-anta_title: ANTA catalog for flow tracking tests
----
-
-
-::: anta.tests.flow_tracking
- 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"
diff --git a/docs/api/tests.logging.md b/docs/api/tests.logging.md
deleted file mode 100644
index 2c4dec0..0000000
--- a/docs/api/tests.logging.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-anta_title: ANTA catalog for logging tests
----
-
-
-::: anta.tests.logging
- 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"
diff --git a/docs/api/tests.md b/docs/api/tests.md
index a36c9eb..0d0deab 100644
--- a/docs/api/tests.md
+++ b/docs/api/tests.md
@@ -1,8 +1,8 @@
---
-anta_title: ANTA Tests Landing Page
+anta_title: ANTA Tests
---
@@ -13,37 +13,37 @@ This section describes all the available tests provided by the ANTA package.
Here are the tests that we currently provide:
-- [AAA](tests.aaa.md)
-- [Adaptive Virtual Topology](tests.avt.md)
-- [BFD](tests.bfd.md)
-- [Configuration](tests.configuration.md)
-- [Connectivity](tests.connectivity.md)
-- [CVX](tests.cvx.md)
-- [Field Notices](tests.field_notices.md)
-- [Flow Tracking](tests.flow_tracking.md)
-- [GreenT](tests.greent.md)
-- [Hardware](tests.hardware.md)
-- [Interfaces](tests.interfaces.md)
-- [LANZ](tests.lanz.md)
-- [Logging](tests.logging.md)
-- [MLAG](tests.mlag.md)
-- [Multicast](tests.multicast.md)
-- [Profiles](tests.profiles.md)
-- [PTP](tests.ptp.md)
-- [Router Path Selection](tests.path_selection.md)
-- [Routing Generic](tests.routing.generic.md)
-- [Routing BGP](tests.routing.bgp.md)
-- [Routing ISIS](tests.routing.isis.md)
-- [Routing OSPF](tests.routing.ospf.md)
-- [Security](tests.security.md)
-- [Services](tests.services.md)
-- [SNMP](tests.snmp.md)
-- [Software](tests.software.md)
-- [STP](tests.stp.md)
-- [STUN](tests.stun.md)
-- [System](tests.system.md)
-- [VLAN](tests.vlan.md)
-- [VXLAN](tests.vxlan.md)
+- [AAA](tests/aaa.md)
+- [Adaptive Virtual Topology](tests/avt.md)
+- [BFD](tests/bfd.md)
+- [Configuration](tests/configuration.md)
+- [Connectivity](tests/connectivity.md)
+- [CVX](tests/cvx.md)
+- [Field Notices](tests/field_notices.md)
+- [Flow Tracking](tests/flow_tracking.md)
+- [GreenT](tests/greent.md)
+- [Hardware](tests/hardware.md)
+- [Interfaces](tests/interfaces.md)
+- [LANZ](tests/lanz.md)
+- [Logging](tests/logging.md)
+- [MLAG](tests/mlag.md)
+- [Multicast](tests/multicast.md)
+- [Profiles](tests/profiles.md)
+- [PTP](tests/ptp.md)
+- [Router Path Selection](tests/path_selection.md)
+- [Routing Generic](tests/routing.generic.md)
+- [Routing BGP](tests/routing.bgp.md)
+- [Routing ISIS](tests/routing.isis.md)
+- [Routing OSPF](tests/routing.ospf.md)
+- [Security](tests/security.md)
+- [Services](tests/services.md)
+- [SNMP](tests/snmp.md)
+- [Software](tests/software.md)
+- [STP](tests/stp.md)
+- [STUN](tests/stun.md)
+- [System](tests/system.md)
+- [VLAN](tests/vlan.md)
+- [VXLAN](tests/vxlan.md)
!!! tip
diff --git a/docs/api/tests.path_selection.md b/docs/api/tests.path_selection.md
deleted file mode 100644
index b7f6b46..0000000
--- a/docs/api/tests.path_selection.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-anta_title: ANTA catalog for Router path-selection tests
----
-
-
-::: 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"
diff --git a/docs/api/tests.routing.isis.md b/docs/api/tests.routing.isis.md
deleted file mode 100644
index 16ca7ff..0000000
--- a/docs/api/tests.routing.isis.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-anta_title: ANTA catalog for IS-IS tests
----
-
-
-::: 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"
- - "!^_[^_]"
diff --git a/docs/api/tests.snmp.md b/docs/api/tests.snmp.md
deleted file mode 100644
index be2da48..0000000
--- a/docs/api/tests.snmp.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-anta_title: ANTA catalog for SNMP tests
----
-
-
-::: anta.tests.snmp
- 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"
diff --git a/docs/api/tests.aaa.md b/docs/api/tests/aaa.md
similarity index 78%
rename from docs/api/tests.aaa.md
rename to docs/api/tests/aaa.md
index e5c4b91..3b4d61a 100644
--- a/docs/api/tests.aaa.md
+++ b/docs/api/tests/aaa.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for AAA tests
---
+
::: anta.tests.aaa
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests/anta_test.md b/docs/api/tests/anta_test.md
new file mode 100644
index 0000000..5d65616
--- /dev/null
+++ b/docs/api/tests/anta_test.md
@@ -0,0 +1,10 @@
+---
+anta_title: ANTA Test API
+---
+
+
+::: anta.models.AntaTest
diff --git a/docs/api/tests.avt.md b/docs/api/tests/avt.md
similarity index 80%
rename from docs/api/tests.avt.md
rename to docs/api/tests/avt.md
index a55fcce..a5d6d71 100644
--- a/docs/api/tests.avt.md
+++ b/docs/api/tests/avt.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for Adaptive Virtual Topology (AVT) tests
---
+
@@ -12,27 +13,31 @@ anta_title: ANTA catalog for Adaptive Virtual Topology (AVT) tests
::: 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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.avt
options:
- show_root_heading: false
- show_root_toc_entry: false
- show_bases: false
anta_hide_test_module_description: true
- merge_init_into_class: false
- show_labels: true
filters:
- "!^__init__"
- "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.bfd.md b/docs/api/tests/bfd.md
similarity index 75%
rename from docs/api/tests.bfd.md
rename to docs/api/tests/bfd.md
index ee95087..d8d92f7 100644
--- a/docs/api/tests.bfd.md
+++ b/docs/api/tests/bfd.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for BFD tests
---
+
@@ -12,25 +13,30 @@ anta_title: ANTA catalog for BFD tests
::: anta.tests.bfd
options:
- show_root_heading: false
- show_root_toc_entry: false
- show_bases: false
- merge_init_into_class: false
- show_labels: true
anta_hide_test_module_description: true
filters:
- "!test"
- "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.bfd
options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
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: ["!^__str__"]
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.configuration.md b/docs/api/tests/configuration.md
similarity index 79%
rename from docs/api/tests.configuration.md
rename to docs/api/tests/configuration.md
index 9b24ea7..60fe8a4 100644
--- a/docs/api/tests.configuration.md
+++ b/docs/api/tests/configuration.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for device configuration tests
---
+
::: anta.tests.configuration
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.connectivity.md b/docs/api/tests/connectivity.md
similarity index 75%
rename from docs/api/tests.connectivity.md
rename to docs/api/tests/connectivity.md
index 439cec8..88aaa5c 100644
--- a/docs/api/tests.connectivity.md
+++ b/docs/api/tests/connectivity.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for connectivity tests
---
+
@@ -12,25 +13,30 @@ anta_title: ANTA catalog for connectivity tests
::: anta.tests.connectivity
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.connectivity
options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
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: ["!^__str__"]
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.cvx.md b/docs/api/tests/cvx.md
similarity index 78%
rename from docs/api/tests.cvx.md
rename to docs/api/tests/cvx.md
index c9ff53d..fdd666f 100644
--- a/docs/api/tests.cvx.md
+++ b/docs/api/tests/cvx.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for CVX tests
---
+
::: anta.tests.cvx
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.field_notices.md b/docs/api/tests/field_notices.md
similarity index 78%
rename from docs/api/tests.field_notices.md
rename to docs/api/tests/field_notices.md
index fe95bf5..745ac9e 100644
--- a/docs/api/tests.field_notices.md
+++ b/docs/api/tests/field_notices.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for Field Notices tests
---
+
::: anta.tests.field_notices
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests/flow_tracking.md b/docs/api/tests/flow_tracking.md
new file mode 100644
index 0000000..f3a9bc4
--- /dev/null
+++ b/docs/api/tests/flow_tracking.md
@@ -0,0 +1,44 @@
+---
+anta_title: ANTA catalog for flow tracking tests
+---
+
+
+
+# Tests
+
+::: anta.tests.flow_tracking
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!test"
+ - "!render"
+ - "!validate_exporters"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
+
+# Input models
+
+::: anta.input_models.flow_tracking
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__init__"
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.greent.md b/docs/api/tests/greent.md
similarity index 78%
rename from docs/api/tests.greent.md
rename to docs/api/tests/greent.md
index 01900a9..797be04 100644
--- a/docs/api/tests.greent.md
+++ b/docs/api/tests/greent.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for GreenT tests
---
+
::: anta.tests.greent
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.hardware.md b/docs/api/tests/hardware.md
similarity index 78%
rename from docs/api/tests.hardware.md
rename to docs/api/tests/hardware.md
index ad06a50..f15c9f1 100644
--- a/docs/api/tests.hardware.md
+++ b/docs/api/tests/hardware.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for hardware tests
---
+
::: anta.tests.hardware
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.interfaces.md b/docs/api/tests/interfaces.md
similarity index 75%
rename from docs/api/tests.interfaces.md
rename to docs/api/tests/interfaces.md
index 3d863ee..1f6def5 100644
--- a/docs/api/tests.interfaces.md
+++ b/docs/api/tests/interfaces.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for interfaces tests
---
+
@@ -12,25 +13,30 @@ anta_title: ANTA catalog for interfaces tests
::: anta.tests.interfaces
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.interfaces
options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
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: ["!^__str__"]
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.lanz.md b/docs/api/tests/lanz.md
similarity index 78%
rename from docs/api/tests.lanz.md
rename to docs/api/tests/lanz.md
index 0685717..a94a157 100644
--- a/docs/api/tests.lanz.md
+++ b/docs/api/tests/lanz.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for LANZ tests
---
+
::: anta.tests.lanz
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests/logging.md b/docs/api/tests/logging.md
new file mode 100644
index 0000000..b305c42
--- /dev/null
+++ b/docs/api/tests/logging.md
@@ -0,0 +1,43 @@
+---
+anta_title: ANTA catalog for logging tests
+---
+
+
+
+# Tests
+
+::: anta.tests.logging
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!test"
+ - "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
+
+# Input models
+
+::: anta.input_models.logging
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!test"
+ - "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.mlag.md b/docs/api/tests/mlag.md
similarity index 78%
rename from docs/api/tests.mlag.md
rename to docs/api/tests/mlag.md
index 5470bcd..d85ae19 100644
--- a/docs/api/tests.mlag.md
+++ b/docs/api/tests/mlag.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for MLAG tests
---
+
::: anta.tests.mlag
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.multicast.md b/docs/api/tests/multicast.md
similarity index 79%
rename from docs/api/tests.multicast.md
rename to docs/api/tests/multicast.md
index 7072796..5116bd6 100644
--- a/docs/api/tests.multicast.md
+++ b/docs/api/tests/multicast.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for multicast and IGMP tests
---
+
::: anta.tests.multicast
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests/path_selection.md b/docs/api/tests/path_selection.md
new file mode 100644
index 0000000..6fd9566
--- /dev/null
+++ b/docs/api/tests/path_selection.md
@@ -0,0 +1,42 @@
+---
+anta_title: ANTA catalog for Router path-selection tests
+---
+
+
+
+# Tests
+
+::: anta.tests.path_selection
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!test"
+ - "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
+
+# Input models
+
+::: anta.input_models.path_selection
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.profiles.md b/docs/api/tests/profiles.md
similarity index 78%
rename from docs/api/tests.profiles.md
rename to docs/api/tests/profiles.md
index a022c6a..c38fa74 100644
--- a/docs/api/tests.profiles.md
+++ b/docs/api/tests/profiles.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for profiles tests
---
+
::: anta.tests.profiles
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.ptp.md b/docs/api/tests/ptp.md
similarity index 78%
rename from docs/api/tests.ptp.md
rename to docs/api/tests/ptp.md
index 3b03ac0..ad09505 100644
--- a/docs/api/tests.ptp.md
+++ b/docs/api/tests/ptp.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for PTP tests
---
+
::: anta.tests.ptp
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.routing.bgp.md b/docs/api/tests/routing.bgp.md
similarity index 57%
rename from docs/api/tests.routing.bgp.md
rename to docs/api/tests/routing.bgp.md
index b40ff7b..a3a0731 100644
--- a/docs/api/tests.routing.bgp.md
+++ b/docs/api/tests/routing.bgp.md
@@ -1,45 +1,55 @@
---
anta_title: ANTA catalog for BGP tests
---
+
-!!! info "`multi-agent` Service Routing Protocols Model Requirements"
- The BGP tests in this section are only supported on switches running the `multi-agent` routing protocols model. Starting from EOS version 4.30.1F, `service routing protocols model` is set to `multi-agent` by default. These BGP commands may **not** be compatible with switches running the legacy `ribd` routing protocols model and may fail if attempted.
+!!! info "BGP Test Compatibility Note"
+ ANTA BGP tests are designed for the `multi-agent` routing protocol model. Starting from EOS 4.30.1F, `service routing protocols models` is set to `multi-agent` by default, and from EOS 4.32.0F it becomes the only supported model.
+
+ The following tests are available for devices using the legacy `ribd` model on earlier EOS versions:
+
+ - `VerifyBGPPeerSessionRibd`
+ - `VerifyBGPPeersHealthRibd`
# Tests
::: anta.tests.routing.bgp
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"
- "!^_[^_]"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.routing.bgp
options:
- show_root_heading: false
- show_root_toc_entry: false
- show_bases: false
anta_hide_test_module_description: true
- merge_init_into_class: false
- show_labels: true
filters:
- "!^__init__"
- "!^__str__"
- "!AFI_SAFI_EOS_KEY"
- "!eos_key"
- "!BgpAfi"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.routing.generic.md b/docs/api/tests/routing.generic.md
similarity index 76%
rename from docs/api/tests.routing.generic.md
rename to docs/api/tests/routing.generic.md
index bbc8904..4977533 100644
--- a/docs/api/tests.routing.generic.md
+++ b/docs/api/tests/routing.generic.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for generic routing tests
---
+
@@ -12,25 +13,30 @@ anta_title: ANTA catalog for generic routing tests
::: anta.tests.routing.generic
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.routing.generic
options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
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: ["!^__str__"]
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests/routing.isis.md b/docs/api/tests/routing.isis.md
new file mode 100644
index 0000000..23fd851
--- /dev/null
+++ b/docs/api/tests/routing.isis.md
@@ -0,0 +1,44 @@
+---
+anta_title: ANTA catalog for IS-IS tests
+---
+
+
+
+# Tests
+
+::: anta.tests.routing.isis
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!test"
+ - "!render"
+ - "!^_[^_]"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
+
+# Input models
+
+::: anta.input_models.routing.isis
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__init__"
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.routing.ospf.md b/docs/api/tests/routing.ospf.md
similarity index 79%
rename from docs/api/tests.routing.ospf.md
rename to docs/api/tests/routing.ospf.md
index 12bb3ec..8f91865 100644
--- a/docs/api/tests.routing.ospf.md
+++ b/docs/api/tests/routing.ospf.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for OSPF tests
---
+
@@ -10,13 +11,15 @@ anta_title: ANTA catalog for OSPF tests
::: anta.tests.routing.ospf
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"
- "!^_[^_]"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.security.md b/docs/api/tests/security.md
similarity index 80%
rename from docs/api/tests.security.md
rename to docs/api/tests/security.md
index 5997832..1b9601c 100644
--- a/docs/api/tests.security.md
+++ b/docs/api/tests/security.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for security tests
---
+
@@ -12,27 +13,31 @@ anta_title: ANTA catalog for security tests
::: anta.tests.security
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.security
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:
- "!^__init__"
- "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.services.md b/docs/api/tests/services.md
similarity index 73%
rename from docs/api/tests.services.md
rename to docs/api/tests/services.md
index cd37148..b44a61c 100644
--- a/docs/api/tests.services.md
+++ b/docs/api/tests/services.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for services tests
---
+
@@ -12,25 +13,31 @@ anta_title: ANTA catalog for services tests
::: anta.tests.services
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.services
options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__init__"
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
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: ["!^__str__"]
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests/snmp.md b/docs/api/tests/snmp.md
new file mode 100644
index 0000000..152014c
--- /dev/null
+++ b/docs/api/tests/snmp.md
@@ -0,0 +1,42 @@
+---
+anta_title: ANTA catalog for SNMP tests
+---
+
+
+
+# Tests
+
+::: anta.tests.snmp
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!test"
+ - "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
+
+# Input models
+
+::: anta.input_models.snmp
+
+ options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.software.md b/docs/api/tests/software.md
similarity index 78%
rename from docs/api/tests.software.md
rename to docs/api/tests/software.md
index 7600476..ce3353a 100644
--- a/docs/api/tests.software.md
+++ b/docs/api/tests/software.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for Software tests
---
+
::: anta.tests.software
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.stp.md b/docs/api/tests/stp.md
similarity index 78%
rename from docs/api/tests.stp.md
rename to docs/api/tests/stp.md
index 50a004b..4729c89 100644
--- a/docs/api/tests.stp.md
+++ b/docs/api/tests/stp.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for STP tests
---
+
::: anta.tests.stp
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.stun.md b/docs/api/tests/stun.md
similarity index 79%
rename from docs/api/tests.stun.md
rename to docs/api/tests/stun.md
index 6a73b88..3c5c205 100644
--- a/docs/api/tests.stun.md
+++ b/docs/api/tests/stun.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for STUN tests
---
+
@@ -10,28 +11,33 @@ anta_title: ANTA catalog for STUN tests
# Tests
::: anta.tests.stun
+
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"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.stun
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:
- "!^__init__"
- "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.system.md b/docs/api/tests/system.md
similarity index 75%
rename from docs/api/tests.system.md
rename to docs/api/tests/system.md
index 26568e2..caa09e7 100644
--- a/docs/api/tests.system.md
+++ b/docs/api/tests/system.md
@@ -1,8 +1,9 @@
---
anta_title: ANTA catalog for System tests
---
+
@@ -12,25 +13,30 @@ anta_title: ANTA catalog for System tests
::: anta.tests.system
options:
- show_root_heading: false
- show_root_toc_entry: false
- show_bases: false
- merge_init_into_class: false
- show_labels: true
anta_hide_test_module_description: true
filters:
- "!test"
- "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
# Input models
::: anta.input_models.system
options:
+ anta_hide_test_module_description: true
+ filters:
+ - "!^__str__"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
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: ["!^__str__"]
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/types.md b/docs/api/tests/types.md
similarity index 72%
rename from docs/api/types.md
rename to docs/api/tests/types.md
index a633e04..1ab5d3a 100644
--- a/docs/api/types.md
+++ b/docs/api/tests/types.md
@@ -1,5 +1,8 @@
+---
+anta_title: ANTA Input Types
+---
diff --git a/docs/api/tests.vlan.md b/docs/api/tests/vlan.md
similarity index 78%
rename from docs/api/tests.vlan.md
rename to docs/api/tests/vlan.md
index 74cb003..04dca0e 100644
--- a/docs/api/tests.vlan.md
+++ b/docs/api/tests/vlan.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for VLAN tests
---
+
::: anta.tests.vlan
+
options:
- show_root_heading: false
- show_root_toc_entry: false
- show_bases: false
- merge_init_into_class: false
- show_labels: true
anta_hide_test_module_description: true
filters:
- "!test"
- "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/api/tests.vxlan.md b/docs/api/tests/vxlan.md
similarity index 78%
rename from docs/api/tests.vxlan.md
rename to docs/api/tests/vxlan.md
index 4c3859a..a79a0b1 100644
--- a/docs/api/tests.vxlan.md
+++ b/docs/api/tests/vxlan.md
@@ -1,20 +1,24 @@
---
anta_title: ANTA catalog for VXLAN tests
---
+
::: anta.tests.vxlan
+
options:
- show_root_heading: false
- show_root_toc_entry: false
- show_bases: false
- merge_init_into_class: false
- show_labels: true
anta_hide_test_module_description: true
filters:
- "!test"
- "!render"
+ merge_init_into_class: false
+ show_bases: false
+ show_labels: true
+ show_root_heading: false
+ show_root_toc_entry: false
+ show_symbol_type_heading: false
+ show_symbol_type_toc: false
diff --git a/docs/cli/check.md b/docs/cli/check.md
index c230722..4c414dc 100644
--- a/docs/cli/check.md
+++ b/docs/cli/check.md
@@ -2,7 +2,7 @@
anta_title: ANTA check commands
---
diff --git a/docs/cli/debug.md b/docs/cli/debug.md
index 45ad791..3cdc784 100644
--- a/docs/cli/debug.md
+++ b/docs/cli/debug.md
@@ -2,7 +2,7 @@
anta_title: ANTA debug commands
---
diff --git a/docs/cli/exec.md b/docs/cli/exec.md
index a7a0fe3..e658b36 100644
--- a/docs/cli/exec.md
+++ b/docs/cli/exec.md
@@ -2,7 +2,7 @@
anta_title: Executing Commands on Devices
---
diff --git a/docs/cli/get-inventory-information.md b/docs/cli/get-inventory-information.md
index d45cb6a..0acd259 100644
--- a/docs/cli/get-inventory-information.md
+++ b/docs/cli/get-inventory-information.md
@@ -2,7 +2,7 @@
anta_title: Retrieving Inventory Information
---
@@ -11,7 +11,7 @@ The ANTA CLI offers multiple commands to access data from your local inventory.
## List devices in inventory
-This command will list all devices available in the inventory. Using the `--tags` option, you can filter this list to only include devices with specific tags (visit [this page](tag-management.md) to learn more about tags). The `--connected` option allows to display only the devices where a connection has been established.
+This command will list all devices available in the inventory. Using the `--tags` option, you can filter this list to only include devices with specific tags (visit [this page](tag-management.md) to learn more about tags). The `--connected` option allows to display only the devices where a connection has been established.
### Command overview
diff --git a/docs/cli/get-tests.md b/docs/cli/get-tests.md
index 3c2b369..2ccccc6 100644
--- a/docs/cli/get-tests.md
+++ b/docs/cli/get-tests.md
@@ -2,7 +2,7 @@
anta_title: Retrieving Tests information
---
diff --git a/docs/cli/inv-from-ansible.md b/docs/cli/inv-from-ansible.md
index c891693..7d5848e 100644
--- a/docs/cli/inv-from-ansible.md
+++ b/docs/cli/inv-from-ansible.md
@@ -2,7 +2,7 @@
anta_title: Create an Inventory from Ansible inventory
---
diff --git a/docs/cli/inv-from-cvp.md b/docs/cli/inv-from-cvp.md
index e08ffd6..0b4c715 100644
--- a/docs/cli/inv-from-cvp.md
+++ b/docs/cli/inv-from-cvp.md
@@ -2,7 +2,7 @@
anta_title: Create an Inventory from CloudVision
---
diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md
index 667eb5f..9e06f36 100644
--- a/docs/cli/nrfu.md
+++ b/docs/cli/nrfu.md
@@ -2,7 +2,7 @@
anta_title: Execute Network Readiness For Use (NRFU) Testing
---
@@ -48,12 +48,7 @@ The `text` subcommand provides a straightforward text report for each test execu
### Command overview
```bash
-Usage: anta nrfu text [OPTIONS]
-
- ANTA command to check network states with text result.
-
-Options:
- --help Show this message and exit.
+--8<-- "anta_nrfu_text_help.txt"
```
### Example
@@ -71,13 +66,7 @@ The `table` command under the `anta nrfu` namespace offers a clear and organized
### Command overview
```bash
-Usage: anta nrfu table [OPTIONS]
-
- ANTA command to check network states with table result.
-
-Options:
- --group-by [device|test] Group result by test or device.
- --help Show this message and exit.
+--8<-- "anta_nrfu_table_help.txt"
```
The `--group-by` option show a summarized view of the test results per host or per test.
@@ -125,15 +114,7 @@ The JSON rendering command in NRFU testing will generate an output of all test r
### Command overview
```bash
-anta nrfu json --help
-Usage: anta nrfu json [OPTIONS]
-
- ANTA command to check network state with JSON result.
-
-Options:
- -o, --output FILE Path to save report as a JSON file [env var:
- ANTA_NRFU_JSON_OUTPUT]
- --help Show this message and exit.
+--8<-- "anta_nrfu_json_help.txt"
```
The `--output` option allows you to save the JSON report as a file. If specified, no output will be displayed in the terminal. This is useful for further processing or integration with other tools.
@@ -153,15 +134,7 @@ The `csv` command in NRFU testing is useful for generating a CSV file with all t
### Command overview
```bash
-anta nrfu csv --help
-Usage: anta nrfu csv [OPTIONS]
-
- ANTA command to check network states with CSV result.
-
-Options:
- --csv-output FILE Path to save report as a CSV file [env var:
- ANTA_NRFU_CSV_CSV_OUTPUT]
- --help Show this message and exit.
+--8<-- "anta_nrfu_csv_help.txt"
```
### Example
@@ -175,16 +148,7 @@ The `md-report` command in NRFU testing generates a comprehensive Markdown repor
### Command overview
```bash
-anta nrfu md-report --help
-
-Usage: anta nrfu md-report [OPTIONS]
-
- ANTA command to check network state with Markdown report.
-
-Options:
- --md-output FILE Path to save the report as a Markdown file [env var:
- ANTA_NRFU_MD_REPORT_MD_OUTPUT; required]
- --help Show this message and exit.
+--8<-- "anta_nrfu_mdreport_help.txt"
```
### Example
@@ -198,17 +162,7 @@ ANTA offers a CLI option for creating custom reports. This leverages the Jinja2
### Command overview
```bash
-anta nrfu tpl-report --help
-Usage: anta nrfu tpl-report [OPTIONS]
-
- ANTA command to check network state with templated report
-
-Options:
- -tpl, --template FILE Path to the template to use for the report [env var:
- ANTA_NRFU_TPL_REPORT_TEMPLATE; required]
- -o, --output FILE Path to save report as a file [env var:
- ANTA_NRFU_TPL_REPORT_OUTPUT]
- --help Show this message and exit.
+--8<-- "anta_nrfu_tplreport_help.txt"
```
The `--template` option is used to specify the Jinja2 template file for generating the custom report.
@@ -231,7 +185,7 @@ The template `./custom_template.j2` is a simple Jinja2 template:
{% endfor %}
```
-The Jinja2 template has access to all `TestResult` elements and their values, as described in this [documentation](../api/result_manager_models.md#anta.result_manager.models.TestResult).
+The Jinja2 template has access to all `TestResult` elements and their values, as described in this [documentation](../api/result.md#anta.result_manager.models.TestResult).
You can also save the report result to a file using the `--output` option:
diff --git a/docs/cli/overview.md b/docs/cli/overview.md
index be6b1f4..dbaafd4 100644
--- a/docs/cli/overview.md
+++ b/docs/cli/overview.md
@@ -2,7 +2,7 @@
anta_title: Overview of ANTA's Command-Line Interface (CLI)
---
@@ -52,17 +52,24 @@ anta nrfu
Below are the environment variables usable with the `anta nrfu` command:
-| Variable Name | Purpose | Required |
-| ------------- | ------- |----------|
-| ANTA_USERNAME | The username to use in the inventory to connect to devices. | Yes |
-| ANTA_PASSWORD | The password to use in the inventory to connect to devices. | Yes |
-| ANTA_INVENTORY | The path to the inventory file. | Yes |
-| ANTA_CATALOG | The path to the catalog file. | Yes |
-| ANTA_PROMPT | The value to pass to the prompt for password is password is not provided | No |
-| ANTA_INSECURE | Whether or not using insecure mode when connecting to the EOS devices HTTP API. | No |
-| ANTA_DISABLE_CACHE | A variable to disable caching for all ANTA tests (enabled by default). | No |
-| ANTA_ENABLE | Whether it is necessary to go to enable mode on devices. | No |
-| ANTA_ENABLE_PASSWORD | The optional enable password, when this variable is set, ANTA_ENABLE or `--enable` is required. | No |
+| Variable Name | Purpose | Required | Default |
+| ------------- | ------- |----------| ------- |
+| ANTA_USERNAME | The username to use in the inventory to connect to devices. | Yes | - |
+| ANTA_PASSWORD | The password to use in the inventory to connect to devices. | Yes | - |
+| ANTA_INVENTORY | The path to the inventory file. | Yes | - |
+| ANTA_CATALOG | The path to the catalog file. | Yes | - |
+| ANTA_ENABLE_PASSWORD | The optional enable password, when this variable is set, ANTA_ENABLE or `--enable` is required. | No | - |
+| ANTA_ENABLE | Whether it is necessary to go to enable mode on devices. | No | False |
+| ANTA_PROMPT | Prompt for passwords if they are not provided. | No | False |
+| ANTA_TIMEOUT | The global timeout value for API calls. | No | 30.0 |
+| ANTA_INSECURE | Whether or not using insecure mode when connecting to the EOS devices HTTP API. | No | False |
+| ANTA_DISABLE_CACHE | A variable to disable caching for all ANTA tests (enabled by default). | No | False |
+| ANTA_INVENTORY_FORMAT | Format of the inventory file. `json` or `yaml`. | No | `yaml` |
+| ANTA_CATALOG_FORMAT | Format of the catalog file. `json` or `yaml`. | No | `yaml` |
+| ANTA_TAGS | A list of tags to filter which tests to run on which devices. | No | - |
+| ANTA_NRFU_IGNORE_STATUS | Exit code will always be 0. | No | False |
+| ANTA_NRFU_IGNORE_ERROR | Exit code will be 0 if all tests succeeded or 1 if any test failed. | No | False |
+| ANTA_NRFU_DRY_RUN | Run `anta nrfu` command but stop before running the tests. | No | False |
> [!NOTE]
> Caching can be disabled with the global parameter `--disable-cache`. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](../advanced_usages/caching.md).
diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md
index b07e0c9..ad03c26 100644
--- a/docs/cli/tag-management.md
+++ b/docs/cli/tag-management.md
@@ -1,5 +1,5 @@
@@ -64,15 +64,14 @@ Tags can be defined in the test catalog to restrict tests to tagged devices:
anta.tests.system:
- VerifyUptime:
minimum: 10
- filters:
- tags: ['spine']
+ filters: tags: [spine]
- VerifyUptime:
minimum: 9
filters:
- tags: ['leaf']
+ tags: [leaf]
- VerifyReloadCause:
filters:
- tags: ['spine', 'leaf']
+ tags: [spine, leaf]
- VerifyCoredump:
- VerifyAgentLogs:
- VerifyCPUUtilization:
@@ -83,13 +82,13 @@ anta.tests.system:
anta.tests.mlag:
- VerifyMlagStatus:
filters:
- tags: ['leaf']
+ tags: [leaf]
anta.tests.interfaces:
- VerifyL3MTU:
mtu: 1500
filters:
- tags: ['spine']
+ tags: [spine]
```
> [!TIP]
@@ -204,35 +203,7 @@ As most ANTA commands accommodate tag filtering, this command is useful for enum
### Command overview
```bash
-Usage: anta get tags [OPTIONS]
-
- Get list of configured tags in user inventory.
-
-Options:
- -u, --username TEXT Username to connect to EOS [env var: ANTA_USERNAME;
- required]
- -p, --password TEXT Password to connect to EOS that must be provided. It
- can be prompted using '--prompt' option. [env var:
- ANTA_PASSWORD]
- --enable-password TEXT Password to access EOS Privileged EXEC mode. It can
- be prompted using '--prompt' option. Requires '--
- enable' option. [env var: ANTA_ENABLE_PASSWORD]
- --enable Some commands may require EOS Privileged EXEC mode.
- This option tries to access this mode before sending
- a command to the device. [env var: ANTA_ENABLE]
- -P, --prompt Prompt for passwords if they are not provided. [env
- var: ANTA_PROMPT]
- --timeout FLOAT Global API timeout. This value will be used for all
- devices. [env var: ANTA_TIMEOUT; default: 30.0]
- --insecure Disable SSH Host Key validation. [env var:
- ANTA_INSECURE]
- --disable-cache Disable cache globally. [env var:
- ANTA_DISABLE_CACHE]
- -i, --inventory FILE Path to the inventory YAML file. [env var:
- ANTA_INVENTORY; required]
- --tags TEXT List of tags using comma as separator:
- tag1,tag2,tag3. [env var: ANTA_TAGS]
- --help Show this message and exit.
+--8<-- anta_get_tags_help.txt
```
### Example
diff --git a/docs/contribution.md b/docs/contribution.md
index 50aed44..f09aa3e 100644
--- a/docs/contribution.md
+++ b/docs/contribution.md
@@ -2,7 +2,7 @@
anta_title: How to contribute to ANTA
---
@@ -29,7 +29,7 @@ $ pip install -e .[dev,cli]
$ pip list -e
Package Version Editable project location
------- ------- -------------------------
-anta 1.2.0 /mnt/lab/projects/anta
+anta 1.3.0 /mnt/lab/projects/anta
```
Then, [`tox`](https://tox.wiki/) is configured with few environments to run CI locally:
diff --git a/docs/faq.md b/docs/faq.md
index ee823b4..2049724 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -3,7 +3,7 @@ toc_depth: 3
anta_title: Frequently Asked Questions (FAQ)
---
diff --git a/docs/getting-started.md b/docs/getting-started.md
index bcd5a2c..878e04b 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,5 +1,5 @@
@@ -86,12 +86,14 @@ This entrypoint has multiple options to manage test coverage and reporting.
--8<-- "anta_help.txt"
```
+To run the NRFU, you need to select an output format amongst [`csv`, `json`, `md-report`, `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
+
```bash
--8<-- "anta_nrfu_help.txt"
```
-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
-
!!! Note
The following examples shows how to pass all the CLI options.
diff --git a/docs/imgs/anta_debug_help.svg b/docs/imgs/anta_debug_help.svg
new file mode 100644
index 0000000..7c8f271
--- /dev/null
+++ b/docs/imgs/anta_debug_help.svg
@@ -0,0 +1,100 @@
+
diff --git a/docs/imgs/anta_help.svg b/docs/imgs/anta_help.svg
new file mode 100644
index 0000000..8a8f8f3
--- /dev/null
+++ b/docs/imgs/anta_help.svg
@@ -0,0 +1,139 @@
+
diff --git a/docs/imgs/anta_nrfu_csv_help.svg b/docs/imgs/anta_nrfu_csv_help.svg
new file mode 100644
index 0000000..8657d55
--- /dev/null
+++ b/docs/imgs/anta_nrfu_csv_help.svg
@@ -0,0 +1,91 @@
+
diff --git a/docs/imgs/anta_nrfu_help.svg b/docs/imgs/anta_nrfu_help.svg
new file mode 100644
index 0000000..d687be3
--- /dev/null
+++ b/docs/imgs/anta_nrfu_help.svg
@@ -0,0 +1,303 @@
+
diff --git a/docs/imgs/anta_nrfu_json_help.svg b/docs/imgs/anta_nrfu_json_help.svg
new file mode 100644
index 0000000..f546ace
--- /dev/null
+++ b/docs/imgs/anta_nrfu_json_help.svg
@@ -0,0 +1,91 @@
+
diff --git a/docs/imgs/anta_nrfu_mdreport_help.svg b/docs/imgs/anta_nrfu_mdreport_help.svg
new file mode 100644
index 0000000..b0c3964
--- /dev/null
+++ b/docs/imgs/anta_nrfu_mdreport_help.svg
@@ -0,0 +1,91 @@
+
diff --git a/docs/imgs/anta_nrfu_table_help.svg b/docs/imgs/anta_nrfu_table_help.svg
new file mode 100644
index 0000000..55448db
--- /dev/null
+++ b/docs/imgs/anta_nrfu_table_help.svg
@@ -0,0 +1,87 @@
+
diff --git a/docs/imgs/anta_nrfu_text_help.svg b/docs/imgs/anta_nrfu_text_help.svg
new file mode 100644
index 0000000..c5929b9
--- /dev/null
+++ b/docs/imgs/anta_nrfu_text_help.svg
@@ -0,0 +1,83 @@
+
diff --git a/docs/imgs/anta_nrfu_tplreport_help.svg b/docs/imgs/anta_nrfu_tplreport_help.svg
new file mode 100644
index 0000000..77f30a0
--- /dev/null
+++ b/docs/imgs/anta_nrfu_tplreport_help.svg
@@ -0,0 +1,99 @@
+
diff --git a/docs/imgs/uml/anta.device.AntaDevice.jpeg b/docs/imgs/uml/anta.device.AntaDevice.jpeg
deleted file mode 100644
index 8d8f91e..0000000
Binary files a/docs/imgs/uml/anta.device.AntaDevice.jpeg and /dev/null differ
diff --git a/docs/imgs/uml/anta.device.AsyncEOSDevice.jpeg b/docs/imgs/uml/anta.device.AsyncEOSDevice.jpeg
deleted file mode 100644
index 255b8e4..0000000
Binary files a/docs/imgs/uml/anta.device.AsyncEOSDevice.jpeg and /dev/null differ
diff --git a/docs/imgs/uml/anta.models.AntaCommand.jpeg b/docs/imgs/uml/anta.models.AntaCommand.jpeg
deleted file mode 100644
index b73b87f..0000000
Binary files a/docs/imgs/uml/anta.models.AntaCommand.jpeg and /dev/null differ
diff --git a/docs/imgs/uml/anta.models.AntaTemplate.jpeg b/docs/imgs/uml/anta.models.AntaTemplate.jpeg
deleted file mode 100644
index 2485cb7..0000000
Binary files a/docs/imgs/uml/anta.models.AntaTemplate.jpeg and /dev/null differ
diff --git a/docs/imgs/uml/anta.models.AntaTest.jpeg b/docs/imgs/uml/anta.models.AntaTest.jpeg
deleted file mode 100644
index 36abed4..0000000
Binary files a/docs/imgs/uml/anta.models.AntaTest.jpeg and /dev/null differ
diff --git a/docs/imgs/uml/anta.result_manager.ResultManager.jpeg b/docs/imgs/uml/anta.result_manager.ResultManager.jpeg
deleted file mode 100644
index 7f29943..0000000
Binary files a/docs/imgs/uml/anta.result_manager.ResultManager.jpeg and /dev/null differ
diff --git a/docs/imgs/uml/anta.result_manager.models.TestResult.jpeg b/docs/imgs/uml/anta.result_manager.models.TestResult.jpeg
deleted file mode 100644
index 25ad998..0000000
Binary files a/docs/imgs/uml/anta.result_manager.models.TestResult.jpeg and /dev/null differ
diff --git a/docs/requirements-and-installation.md b/docs/requirements-and-installation.md
index e9fbbc7..e6b02bf 100644
--- a/docs/requirements-and-installation.md
+++ b/docs/requirements-and-installation.md
@@ -1,5 +1,5 @@
@@ -84,7 +84,7 @@ which anta
```bash
# Check ANTA version
anta --version
-anta, version v1.2.0
+anta, version v1.3.0
```
## EOS Requirements
diff --git a/docs/scripts/__init__.py b/docs/scripts/__init__.py
deleted file mode 100644
index c6adabb..0000000
--- a/docs/scripts/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# 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.
-"""Scripts for ANTA documentation."""
diff --git a/docs/scripts/generate_doc_snippets.py b/docs/scripts/generate_doc_snippets.py
new file mode 100755
index 0000000..6ffa4ed
--- /dev/null
+++ b/docs/scripts/generate_doc_snippets.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python
+# Copyright (c) 2024-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Generates SVG for documentation purposes."""
+
+import sys
+from pathlib import Path
+
+# TODO: svg in another PR
+from generate_snippet import main as generate_snippet
+
+sys.path.insert(0, str(Path(__file__).parents[2]))
+
+COMMANDS = [
+ "anta --help",
+ "anta nrfu --help",
+ "anta nrfu csv --help",
+ "anta nrfu json --help",
+ "anta nrfu table --help",
+ "anta nrfu text --help",
+ "anta nrfu tpl-report --help",
+ "anta nrfu md-report --help",
+ "anta get tags --help",
+]
+
+for command in COMMANDS:
+ # TODO: svg in another PR
+ generate_snippet(command.split(" "), output="txt")
diff --git a/docs/scripts/generate_examples_tests.py b/docs/scripts/generate_examples_tests.py
index a88d9d6..6bf77e2 100755
--- a/docs/scripts/generate_examples_tests.py
+++ b/docs/scripts/generate_examples_tests.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Generates examples/tests.py."""
diff --git a/docs/scripts/generate_svg.py b/docs/scripts/generate_snippet.py
old mode 100644
new mode 100755
similarity index 60%
rename from docs/scripts/generate_svg.py
rename to docs/scripts/generate_snippet.py
index 2eca6ac..da30645
--- a/docs/scripts/generate_svg.py
+++ b/docs/scripts/generate_snippet.py
@@ -1,11 +1,12 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+#!/usr/bin/env python
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
-"""A script to generate svg files from anta command.
+"""A script to generate svg or txt files from anta command.
usage:
-python generate_svg.py anta ...
+python generate_snippet.py anta ...
"""
# This script is not a package
# ruff: noqa: INP001
@@ -20,12 +21,15 @@ import sys
from contextlib import redirect_stdout, suppress
from importlib import import_module
from importlib.metadata import entry_points
+from typing import Literal
from unittest.mock import patch
-from rich.console import Console
from rich.logging import RichHandler
+from rich.markup import escape
from rich.progress import Progress
+sys.path.insert(0, str(pathlib.Path(__file__).parents[2]))
+
from anta.cli.console import console
from anta.cli.nrfu.utils import anta_progress_bar
@@ -35,9 +39,6 @@ r = RichHandler(console=console)
root.addHandler(r)
-OUTPUT_DIR = pathlib.Path(__file__).parent.parent / "imgs"
-
-
def custom_progress_bar() -> Progress:
"""Set the console of progress_bar to main anta console.
@@ -50,12 +51,14 @@ def custom_progress_bar() -> Progress:
return progress
-if __name__ == "__main__":
+def main(args: list[str], output: Literal["svg", "txt"] = "svg") -> None:
+ """Execute the script."""
# Sane rich size
os.environ["COLUMNS"] = "120"
+ output_dir = pathlib.Path(__file__).parent.parent / "snippets" if output == "txt" else pathlib.Path(__file__).parent.parent / "imgs"
+
# stolen from https://github.com/ewels/rich-click/blob/main/src/rich_click/cli.py
- args = sys.argv[1:]
script_name = args[0]
console_scripts = entry_points(group="console_scripts")
scripts = {script.name: script for script in console_scripts}
@@ -80,27 +83,32 @@ if __name__ == "__main__":
module = import_module(module_path)
function = getattr(module, function_name)
- # Console to captur everything
- new_console = Console(record=True)
-
pipe = io.StringIO()
console.record = True
console.file = pipe
- with redirect_stdout(io.StringIO()) as f:
- # tweaks to record and redirect to a dummy file
-
- console.print(f"ant@anthill$ {' '.join(sys.argv)}")
-
- # Redirect stdout of the program towards another StringIO to capture help
- # that is not part or anta rich console
- # redirect potential progress bar output to console by patching
- with patch("anta.cli.nrfu.utils.anta_progress_bar", custom_progress_bar), suppress(SystemExit):
- function()
+ # Redirect stdout of the program towards another StringIO to capture help
+ # that is not part or anta rich console
+ # redirect potential progress bar output to console by patching
+ with redirect_stdout(io.StringIO()) as f, patch("anta.cli.nrfu.utils.anta_progress_bar", custom_progress_bar), suppress(SystemExit):
+ if output == "txt":
+ console.print(f"$ {' '.join(sys.argv)}")
+ function()
if "--help" in args:
- console.print(f.getvalue())
+ console.print(escape(f.getvalue()))
+
+ filename = f"{'_'.join(x.replace('/', '_').replace('-', '').replace('.', '') for x in args)}.{output}"
+ filename = output_dir / filename
+ if output == "txt":
+ content = console.export_text()[:-1]
+ with filename.open("w") as fd:
+ fd.write(content)
+ # TODO: Not using this to avoid newline console.save_text(str(filename))
+ elif output == "svg":
+ console.save_svg(str(filename), title=" ".join(args))
- filename = f"{'_'.join(x.replace('/', '_').replace('-', '_').replace('.', '_') for x in args)}.svg"
- filename = f"{OUTPUT_DIR}/{filename}"
print(f"File saved at {filename}")
- console.save_svg(filename, title=" ".join(args))
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:], "txt")
diff --git a/docs/snippets/anta_debug_help.txt b/docs/snippets/anta_debug_help.txt
new file mode 100644
index 0000000..0b74be2
--- /dev/null
+++ b/docs/snippets/anta_debug_help.txt
@@ -0,0 +1,11 @@
+$ anta debug --help
+Usage: anta debug [OPTIONS] COMMAND [ARGS]...
+
+ Commands to execute EOS commands on remote devices.
+
+Options:
+ --help Show this message and exit.
+
+Commands:
+ run-cmd Run arbitrary command to an ANTA device.
+ run-template Run arbitrary templated command to an ANTA device.
diff --git a/docs/snippets/anta_get_tags_help.txt b/docs/snippets/anta_get_tags_help.txt
new file mode 100644
index 0000000..32f7f9e
--- /dev/null
+++ b/docs/snippets/anta_get_tags_help.txt
@@ -0,0 +1,35 @@
+$ anta get tags --help
+Usage: anta get tags [OPTIONS]
+
+ Get list of configured tags in user inventory.
+
+Options:
+ -u, --username TEXT Username to connect to EOS [env var:
+ ANTA_USERNAME; required]
+ -p, --password TEXT Password to connect to EOS that must be
+ provided. It can be prompted using '--
+ prompt' option. [env var: ANTA_PASSWORD]
+ --enable-password TEXT Password to access EOS Privileged EXEC mode.
+ It can be prompted using '--prompt' option.
+ Requires '--enable' option. [env var:
+ ANTA_ENABLE_PASSWORD]
+ --enable Some commands may require EOS Privileged
+ EXEC mode. This option tries to access this
+ mode before sending a command to the device.
+ [env var: ANTA_ENABLE]
+ -P, --prompt Prompt for passwords if they are not
+ provided. [env var: ANTA_PROMPT]
+ --timeout FLOAT Global API timeout. This value will be used
+ for all devices. [env var: ANTA_TIMEOUT;
+ default: 30.0]
+ --insecure Disable SSH Host Key validation. [env var:
+ ANTA_INSECURE]
+ --disable-cache Disable cache globally. [env var:
+ ANTA_DISABLE_CACHE]
+ -i, --inventory FILE Path to the inventory YAML file. [env var:
+ ANTA_INVENTORY; required]
+ --inventory-format [yaml|json] Format of the inventory file, either 'yaml'
+ or 'json' [env var: ANTA_INVENTORY_FORMAT]
+ --tags TEXT List of tags using comma as separator:
+ tag1,tag2,tag3. [env var: ANTA_TAGS]
+ --help Show this message and exit.
diff --git a/docs/snippets/anta_help.txt b/docs/snippets/anta_help.txt
index 7bc37ad..dd552fd 100644
--- a/docs/snippets/anta_help.txt
+++ b/docs/snippets/anta_help.txt
@@ -1,8 +1,10 @@
+$ anta --help
Usage: anta [OPTIONS] COMMAND [ARGS]...
Arista Network Test Automation (ANTA) CLI.
Options:
+ --help Show this message and exit.
--version Show the version and exit.
--log-file FILE Send the logs to a file. If logging level is
DEBUG, only INFO or higher will be sent to
@@ -10,7 +12,6 @@ Options:
-l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]
ANTA logging level [env var:
ANTA_LOG_LEVEL; default: INFO]
- --help Show this message and exit.
Commands:
check Commands to validate configuration files.
diff --git a/docs/snippets/anta_nrfu_csv_help.txt b/docs/snippets/anta_nrfu_csv_help.txt
new file mode 100644
index 0000000..483b1c7
--- /dev/null
+++ b/docs/snippets/anta_nrfu_csv_help.txt
@@ -0,0 +1,9 @@
+$ anta nrfu csv --help
+Usage: anta nrfu csv [OPTIONS]
+
+ ANTA command to check network state with CSV report.
+
+Options:
+ --csv-output FILE Path to save report as a CSV file [env var:
+ ANTA_NRFU_CSV_CSV_OUTPUT; required]
+ --help Show this message and exit.
diff --git a/docs/snippets/anta_nrfu_help.txt b/docs/snippets/anta_nrfu_help.txt
index cb23fa7..5500389 100644
--- a/docs/snippets/anta_nrfu_help.txt
+++ b/docs/snippets/anta_nrfu_help.txt
@@ -1,3 +1,4 @@
+$ anta nrfu --help
Usage: anta nrfu [OPTIONS] COMMAND [ARGS]...
Run ANTA tests on selected inventory devices.
@@ -27,6 +28,8 @@ Options:
ANTA_DISABLE_CACHE]
-i, --inventory FILE Path to the inventory YAML file. [env var:
ANTA_INVENTORY; required]
+ --inventory-format [yaml|json] Format of the inventory file, either 'yaml'
+ or 'json' [env var: ANTA_INVENTORY_FORMAT]
--tags TEXT List of tags using comma as separator:
tag1,tag2,tag3. [env var: ANTA_TAGS]
-c, --catalog FILE Path to the test catalog file [env var:
diff --git a/docs/snippets/anta_nrfu_json_help.txt b/docs/snippets/anta_nrfu_json_help.txt
new file mode 100644
index 0000000..6aebec9
--- /dev/null
+++ b/docs/snippets/anta_nrfu_json_help.txt
@@ -0,0 +1,11 @@
+$ anta nrfu json --help
+Usage: anta nrfu json [OPTIONS]
+
+ ANTA command to check network state with JSON results.
+
+ If no `--output` is specified, the output is printed to stdout.
+
+Options:
+ -o, --output FILE Path to save report as a JSON file [env var:
+ ANTA_NRFU_JSON_OUTPUT]
+ --help Show this message and exit.
diff --git a/docs/snippets/anta_nrfu_mdreport_help.txt b/docs/snippets/anta_nrfu_mdreport_help.txt
new file mode 100644
index 0000000..0d45811
--- /dev/null
+++ b/docs/snippets/anta_nrfu_mdreport_help.txt
@@ -0,0 +1,9 @@
+$ anta nrfu md-report --help
+Usage: anta nrfu md-report [OPTIONS]
+
+ ANTA command to check network state with Markdown report.
+
+Options:
+ --md-output FILE Path to save the report as a Markdown file [env var:
+ ANTA_NRFU_MD_REPORT_MD_OUTPUT; required]
+ --help Show this message and exit.
diff --git a/docs/snippets/anta_nrfu_table_help.txt b/docs/snippets/anta_nrfu_table_help.txt
new file mode 100644
index 0000000..9d368ab
--- /dev/null
+++ b/docs/snippets/anta_nrfu_table_help.txt
@@ -0,0 +1,8 @@
+$ anta nrfu table --help
+Usage: anta nrfu table [OPTIONS]
+
+ ANTA command to check network state with table results.
+
+Options:
+ --group-by [device|test] Group result by test or device.
+ --help Show this message and exit.
diff --git a/docs/snippets/anta_nrfu_text_help.txt b/docs/snippets/anta_nrfu_text_help.txt
new file mode 100644
index 0000000..3bc587a
--- /dev/null
+++ b/docs/snippets/anta_nrfu_text_help.txt
@@ -0,0 +1,7 @@
+$ anta nrfu text --help
+Usage: anta nrfu text [OPTIONS]
+
+ ANTA command to check network state with text results.
+
+Options:
+ --help Show this message and exit.
diff --git a/docs/snippets/anta_nrfu_tplreport_help.txt b/docs/snippets/anta_nrfu_tplreport_help.txt
new file mode 100644
index 0000000..b19bc8c
--- /dev/null
+++ b/docs/snippets/anta_nrfu_tplreport_help.txt
@@ -0,0 +1,11 @@
+$ anta nrfu tpl-report --help
+Usage: anta nrfu tpl-report [OPTIONS]
+
+ ANTA command to check network state with templated report.
+
+Options:
+ -tpl, --template FILE Path to the template to use for the report [env var:
+ ANTA_NRFU_TPL_REPORT_TEMPLATE; required]
+ -o, --output FILE Path to save report as a file [env var:
+ ANTA_NRFU_TPL_REPORT_OUTPUT]
+ --help Show this message and exit.
diff --git a/docs/stylesheets/extra.material.css b/docs/stylesheets/extra.material.css
index 2476f8c..7360793 100644
--- a/docs/stylesheets/extra.material.css
+++ b/docs/stylesheets/extra.material.css
@@ -194,7 +194,7 @@
color: var(--md-default-fg-color--lighter)
} */
.md-typeset__table {
- min-width: 80%;
+ min-width: 30%;
}
.md-typeset table:not([class]) {
display: table;
@@ -243,3 +243,7 @@ h5.doc-heading {
/* Avoid to capitalize h5 headers for mkdocstrings */
text-transform: none;
}
+
+.pydantic_pink {
+ color: #C64666;
+}
diff --git a/docs/templates/python/material/anta_test.html.jinja b/docs/templates/python/material/anta_test.html.jinja
index a40d86a..722c6b3 100644
--- a/docs/templates/python/material/anta_test.html.jinja
+++ b/docs/templates/python/material/anta_test.html.jinja
@@ -57,7 +57,7 @@
{% set heading_level = heading_level + 1 %}
{% set old_obj = obj %}
{% set obj = class %}
- {% include "attributes_table.html" with context %}
+ {% include "attributes_table.html.jinja" with context %}
{% set obj = old_obj %}
{% else %}
{% if members_list is not none or class.is_public %}
diff --git a/docs/templates/python/material/anta_test_input_model.html.jinja b/docs/templates/python/material/anta_test_input_model.html.jinja
index f867ad0..f799fd8 100644
--- a/docs/templates/python/material/anta_test_input_model.html.jinja
+++ b/docs/templates/python/material/anta_test_input_model.html.jinja
@@ -34,7 +34,7 @@
{% set heading_level = heading_level + 1 %}
{% set old_obj = obj %}
{% set obj = class %}
- {% include "attributes_table.html" with context %}
+ {% include "attributes_table.html.jinja" with context %}
{% set obj = old_obj %}
{% endwith %}
{% endif %}
diff --git a/docs/templates/python/material/attributes_table.html b/docs/templates/python/material/attributes_table.html.jinja
similarity index 97%
rename from docs/templates/python/material/attributes_table.html
rename to docs/templates/python/material/attributes_table.html.jinja
index 4997145..3ab4ba0 100644
--- a/docs/templates/python/material/attributes_table.html
+++ b/docs/templates/python/material/attributes_table.html.jinja
@@ -61,7 +61,7 @@
{% set heading_level = heading_level + 1 %}
{% set old_obj = obj %}
{% set obj = class %}
- {% include "attributes_table.html" with context %}
+ {% include "attributes_table.html.jinja" with context %}
{% set obj = old_obj %}
{% endfor %}
diff --git a/docs/templates/python/material/class.html.jinja b/docs/templates/python/material/class.html.jinja
index cbf9fac..e8e3de0 100644
--- a/docs/templates/python/material/class.html.jinja
+++ b/docs/templates/python/material/class.html.jinja
@@ -59,7 +59,7 @@
{{ super() }}
{% for dec in class.decorators %}
-{% if dec.value.function.name == "deprecated_test_class" %}
+{% if dec.value.function is defined and dec.value.function.name == "deprecated_test_class" %}
{% for arg in dec.value.arguments | selectattr("name", "equalto", "removal_in_version") | list %}
diff --git a/docs/templates/python/material/docstring.html.jinja b/docs/templates/python/material/docstring.html.jinja
deleted file mode 100644
index b12ae7e..0000000
--- a/docs/templates/python/material/docstring.html.jinja
+++ /dev/null
@@ -1,36 +0,0 @@
-{% if docstring_sections %}
- {{ log.debug("Rendering docstring") }}
- {% for section in docstring_sections %}
- {% if config.show_docstring_description and section.kind.value == "text" %}
- {% if not (config.anta_hide_test_module_description and module == obj) %}
- {{ section.value|convert_markdown(heading_level, html_id) }}
- {% endif %}
- {% elif config.show_docstring_attributes and section.kind.value == "attributes" %}
- {% include "docstring/attributes.html" with context %}
- {% elif config.show_docstring_functions and section.kind.value == "functions" %}
- {% include "docstring/functions.html" with context %}
- {% elif config.show_docstring_classes and section.kind.value == "classes" %}
- {% include "docstring/classes.html" with context %}
- {% elif config.show_docstring_modules and section.kind.value == "modules" %}
- {% include "docstring/modules.html" with context %}
- {% elif config.show_docstring_parameters and section.kind.value == "parameters" %}
- {% include "docstring/parameters.html" with context %}
- {% elif config.show_docstring_other_parameters and section.kind.value == "other parameters" %}
- {% include "docstring/other_parameters.html" with context %}
- {% elif config.show_docstring_raises and section.kind.value == "raises" %}
- {% include "docstring/raises.html" with context %}
- {% elif config.show_docstring_warns and section.kind.value == "warns" %}
- {% include "docstring/warns.html" with context %}
- {% elif config.show_docstring_yields and section.kind.value == "yields" %}
- {% include "docstring/yields.html" with context %}
- {% elif config.show_docstring_receives and section.kind.value == "receives" %}
- {% include "docstring/receives.html" with context %}
- {% elif config.show_docstring_returns and section.kind.value == "returns" %}
- {% include "docstring/returns.html" with context %}
- {% elif config.show_docstring_examples and section.kind.value == "examples" %}
- {% include "docstring/examples.html" with context %}
- {% elif config.show_docstring_description and section.kind.value == "admonition" %}
- {% include "docstring/admonition.html" with context %}
- {% endif %}
- {% endfor %}
-{% endif %}
diff --git a/docs/templates/python/material/docstring/examples.html b/docs/templates/python/material/docstring/examples.html.jinja
similarity index 100%
rename from docs/templates/python/material/docstring/examples.html
rename to docs/templates/python/material/docstring/examples.html.jinja
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index a422f7c..f12f33a 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -1,5 +1,5 @@
diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md
index 7baebfb..dd9d30b 100644
--- a/docs/usage-inventory-catalog.md
+++ b/docs/usage-inventory-catalog.md
@@ -1,5 +1,5 @@
@@ -45,7 +45,7 @@ The inventory file must start with the `anta_inventory` key then define one or m
- `networks`: scan a network for devices accessible via eAPI
- `ranges`: scan a range for devices accessible via eAPI
-A full description of the inventory model is available in [API documentation](api/inventory.models.input.md)
+A full description of the inventory model is available in [API documentation](api/inventory.md)
> [!INFO]
> Caching can be disabled per device, network or range by setting the `disable_cache` key to `True` in the inventory file. For more details about how caching is implemented in ANTA, please refer to [Caching in ANTA](advanced_usages/caching.md).
diff --git a/examples/tests.yaml b/examples/tests.yaml
index e22acf4..d53150e 100644
--- a/examples/tests.yaml
+++ b/examples/tests.yaml
@@ -87,6 +87,7 @@ anta.tests.bfd:
tx_interval: 1200
rx_interval: 1200
multiplier: 3
+ detection_time: 3600
- VerifyBFDPeersRegProtocols:
# Verifies the registered routing protocol of IPv4 BFD peer sessions.
bfd_peers:
@@ -129,11 +130,18 @@ anta.tests.connectivity:
vrf: MGMT
df_bit: True
size: 100
+ reachable: true
- source: Management0
destination: 8.8.8.8
vrf: MGMT
df_bit: True
size: 100
+ - source: fd12:3456:789a:1::1
+ destination: fd12:3456:789a:1::2
+ vrf: default
+ df_bit: True
+ size: 100
+ reachable: false
anta.tests.cvx:
- VerifyActiveCVXConnections:
# Verifies the number of active CVX Connections.
@@ -161,7 +169,7 @@ anta.tests.field_notices:
# Verifies if the device is exposed to FN0072, and if the issue has been mitigated.
anta.tests.flow_tracking:
- VerifyHardwareFlowTrackerStatus:
- # Verifies if hardware flow tracking is running and an input tracker is active. Optionally verifies the tracker interval/timeout and exporter configuration.
+ # Verifies the hardware flow tracking state.
trackers:
- name: FLOW-TRACKER
record_export:
@@ -286,10 +294,20 @@ anta.tests.lanz:
anta.tests.logging:
- VerifyLoggingAccounting:
# Verifies if AAA accounting logs are generated.
+ - VerifyLoggingEntries:
+ # Verifies that the expected log string is present in the last specified log messages.
+ logging_entries:
+ - regex_match: ".ACCOUNTING-5-EXEC: cvpadmin ssh."
+ last_number_messages: 30
+ severity_level: alerts
+ - regex_match: ".SPANTREE-6-INTERFACE_ADD:."
+ last_number_messages: 10
+ severity_level: critical
- VerifyLoggingErrors:
# Verifies there are no syslog messages with a severity of ERRORS or higher.
- VerifyLoggingHostname:
# Verifies if logs are generated with the device FQDN.
+ severity_level: informational
- VerifyLoggingHosts:
# Verifies logging hosts (syslog servers) for a specified VRF.
hosts:
@@ -298,6 +316,7 @@ anta.tests.logging:
vrf: default
- VerifyLoggingLogsGeneration:
# Verifies if logs are generated.
+ severity_level: informational
- VerifyLoggingPersistent:
# Verifies if logging persistent is enabled and logs are saved in flash.
- VerifyLoggingSourceIntf:
@@ -306,6 +325,9 @@ anta.tests.logging:
vrf: default
- VerifyLoggingTimestamp:
# Verifies if logs are generated with the appropriate timestamp.
+ severity_level: informational
+ - VerifySyslogLogging:
+ # Verifies if syslog logging is enabled.
anta.tests.mlag:
- VerifyMlagConfigSanity:
# Verifies there are no MLAG config-sanity inconsistencies.
@@ -339,7 +361,7 @@ anta.tests.path_selection:
- VerifyPathsHealth:
# Verifies the path and telemetry state of all paths under router path-selection.
- VerifySpecificPath:
- # Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection.
+ # Verifies the DPS path and telemetry state of an IPv4 peer.
paths:
- peer: 10.255.0.1
path_group: internet
@@ -366,14 +388,14 @@ anta.tests.ptp:
# Verifies the PTP interfaces state.
anta.tests.routing.bgp:
- VerifyBGPAdvCommunities:
- # Verifies that advertised communities are standard, extended and large for BGP peers.
+ # Verifies that advertised communities are standard, extended and large for BGP IPv4 peer(s).
bgp_peers:
- peer_address: 172.30.11.17
vrf: default
- peer_address: 172.30.11.21
vrf: default
- VerifyBGPExchangedRoutes:
- # Verifies the advertised and received routes of BGP peers.
+ # Verifies the advertised and received routes of BGP IPv4 peer(s).
bgp_peers:
- peer_address: 172.30.255.5
vrf: default
@@ -386,10 +408,15 @@ anta.tests.routing.bgp:
advertised_routes:
- 192.0.255.1/32
- 192.0.254.5/32
- received_routes:
- - 192.0.254.3/32
+ - VerifyBGPNlriAcceptance:
+ # Verifies that all received NLRI are accepted for all AFI/SAFI configured for BGP IPv4 peer(s).
+ bgp_peers:
+ - peer_address: 10.100.0.128
+ vrf: default
+ capabilities:
+ - ipv4Unicast
- VerifyBGPPeerASNCap:
- # Verifies the four octet ASN capability of BGP peers.
+ # Verifies the four octet ASN capability of BGP IPv4 peer(s).
bgp_peers:
- peer_address: 172.30.11.1
vrf: default
@@ -418,33 +445,77 @@ anta.tests.routing.bgp:
drop_stats:
- inDropAsloop
- prefixEvpnDroppedUnsupportedRouteType
+ - VerifyBGPPeerGroup:
+ # Verifies BGP peer group of BGP IPv4 peer(s).
+ bgp_peers:
+ - peer_address: 172.30.11.1
+ vrf: default
+ peer_group: IPv4-UNDERLAY-PEERS
- VerifyBGPPeerMD5Auth:
- # Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF.
+ # Verifies the MD5 authentication and state of IPv4 BGP peer(s) in a specified VRF.
bgp_peers:
- peer_address: 172.30.11.1
vrf: default
- peer_address: 172.30.11.5
vrf: default
- VerifyBGPPeerMPCaps:
- # Verifies the multiprotocol capabilities of BGP peers.
+ # Verifies the multiprotocol capabilities of BGP IPv4 peer(s).
bgp_peers:
- peer_address: 172.30.11.1
vrf: default
strict: False
capabilities:
- - ipv4Unicast
+ - ipv4 labeled-Unicast
+ - ipv4MplsVpn
- VerifyBGPPeerRouteLimit:
- # Verifies maximum routes and outbound route-maps of BGP IPv4 peer(s).
+ # Verifies maximum routes and warning limit for BGP IPv4 peer(s).
bgp_peers:
- peer_address: 172.30.11.1
vrf: default
maximum_routes: 12000
warning_limit: 10000
- VerifyBGPPeerRouteRefreshCap:
- # Verifies the route refresh capabilities of a BGP peer in a specified VRF.
+ # Verifies the route refresh capabilities of IPv4 BGP peer(s) in a specified VRF.
bgp_peers:
- peer_address: 172.30.11.1
vrf: default
+ - VerifyBGPPeerSession:
+ # Verifies the session state of BGP IPv4 peer(s).
+ minimum_established_time: 10000
+ check_tcp_queues: false
+ bgp_peers:
+ - peer_address: 10.1.0.1
+ vrf: default
+ - peer_address: 10.1.0.2
+ vrf: default
+ - peer_address: 10.1.255.2
+ vrf: DEV
+ - peer_address: 10.1.255.4
+ vrf: DEV
+ - VerifyBGPPeerSessionRibd:
+ # Verifies the session state of BGP IPv4 peer(s).
+ minimum_established_time: 10000
+ check_tcp_queues: false
+ bgp_peers:
+ - peer_address: 10.1.0.1
+ vrf: default
+ - peer_address: 10.1.0.2
+ vrf: default
+ - peer_address: 10.1.255.2
+ vrf: DEV
+ - peer_address: 10.1.255.4
+ vrf: DEV
+ - VerifyBGPPeerTtlMultiHops:
+ # Verifies BGP TTL and max-ttl-hops count for BGP IPv4 peer(s).
+ bgp_peers:
+ - peer_address: 172.30.11.1
+ vrf: default
+ ttl: 3
+ max_ttl_hops: 3
+ - peer_address: 172.30.11.2
+ vrf: test
+ ttl: 30
+ max_ttl_hops: 30
- VerifyBGPPeerUpdateErrors:
# Verifies BGP update error counters for the provided BGP IPv4 peer(s).
bgp_peers:
@@ -454,6 +525,7 @@ anta.tests.routing.bgp:
- inUpdErrWithdraw
- VerifyBGPPeersHealth:
# Verifies the health of BGP peers for given address families.
+ minimum_established_time: 10000
address_families:
- afi: "evpn"
- afi: "ipv4"
@@ -463,8 +535,48 @@ anta.tests.routing.bgp:
safi: "unicast"
vrf: "DEV"
check_tcp_queues: false
+ - VerifyBGPPeersHealthRibd:
+ # Verifies the health of all the BGP IPv4 peer(s).
+ check_tcp_queues: True
+ - VerifyBGPRedistribution:
+ # Verifies BGP redistribution.
+ vrfs:
+ - vrf: default
+ address_families:
+ - afi_safi: ipv4multicast
+ redistributed_routes:
+ - proto: Connected
+ include_leaked: True
+ route_map: RM-CONN-2-BGP
+ - proto: IS-IS
+ include_leaked: True
+ route_map: RM-CONN-2-BGP
+ - afi_safi: IPv6 Unicast
+ redistributed_routes:
+ - proto: User # Converted to EOS SDK
+ route_map: RM-CONN-2-BGP
+ - proto: Static
+ include_leaked: True
+ route_map: RM-CONN-2-BGP
+ - VerifyBGPRouteECMP:
+ # Verifies BGP IPv4 route ECMP paths.
+ route_entries:
+ - prefix: 10.100.0.128/31
+ vrf: default
+ ecmp_count: 2
+ - VerifyBGPRoutePaths:
+ # Verifies BGP IPv4 route paths.
+ route_entries:
+ - prefix: 10.100.0.128/31
+ vrf: default
+ paths:
+ - nexthop: 10.100.0.10
+ origin: Igp
+ - nexthop: 10.100.4.5
+ origin: Incomplete
- VerifyBGPSpecificPeers:
# Verifies the health of specific BGP peer(s) for given address families.
+ minimum_established_time: 10000
address_families:
- afi: "evpn"
peers:
@@ -478,7 +590,7 @@ anta.tests.routing.bgp:
- 10.1.255.2
- 10.1.255.4
- VerifyBGPTimers:
- # Verifies the timers of BGP peers.
+ # Verifies the timers of BGP IPv4 peer(s).
bgp_peers:
- peer_address: 172.30.11.1
vrf: default
@@ -503,6 +615,15 @@ anta.tests.routing.bgp:
- address: aac1.ab5d.b41e
vni: 10010
anta.tests.routing.generic:
+ - VerifyIPv4RouteNextHops:
+ # Verifies the next-hops of the IPv4 prefixes.
+ route_entries:
+ - prefix: 10.10.0.1/32
+ vrf: default
+ strict: false
+ nexthops:
+ - 10.100.0.8
+ - 10.100.0.10
- VerifyIPv4RouteType:
# Verifies the route-type of the IPv4 prefixes.
routes_entries:
@@ -530,21 +651,18 @@ anta.tests.routing.generic:
maximum: 20
anta.tests.routing.isis:
- VerifyISISInterfaceMode:
- # Verifies interface mode for IS-IS
+ # Verifies IS-IS interfaces are running in the correct mode.
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
+ vrf: PROD
- VerifyISISNeighborCount:
- # Verifies number of IS-IS neighbors per level and per interface.
+ # Verifies the number of IS-IS neighbors per interface and level.
interfaces:
- name: Ethernet1
level: 1
@@ -554,11 +672,11 @@ anta.tests.routing.isis:
count: 1
- name: Ethernet3
count: 2
- # level is set to 2 by default
- VerifyISISNeighborState:
- # Verifies all IS-IS neighbors are in UP state.
+ # Verifies the health of IS-IS neighbors.
+ check_all_vrfs: true
- VerifyISISSegmentRoutingAdjacencySegments:
- # Verify that all expected Adjacency segments are correctly visible for each interface.
+ # Verifies IS-IS segment routing adjacency segments.
instances:
- name: CORE-ISIS
vrf: default
@@ -567,7 +685,7 @@ anta.tests.routing.isis:
address: 10.0.1.3
sid_origin: dynamic
- VerifyISISSegmentRoutingDataplane:
- # Verify dataplane of a list of ISIS-SR instances.
+ # Verifies IS-IS segment routing data-plane configuration.
instances:
- name: CORE-ISIS
vrf: default
@@ -695,12 +813,14 @@ anta.tests.services:
vrf: MGMT
priority: 0
- VerifyErrdisableRecovery:
- # Verifies the errdisable recovery reason, status, and interval.
+ # Verifies the error disable recovery functionality.
reasons:
- reason: acl
interval: 30
+ status: Enabled
- reason: bpduguard
interval: 30
+ status: Enabled
- VerifyHostname:
# Verifies the hostname of a device.
hostname: s1-spine1
@@ -712,6 +832,27 @@ anta.tests.snmp:
# Verifies the SNMP error counters.
error_counters:
- inVersionErrs
+ - VerifySnmpGroup:
+ # Verifies the SNMP group configurations for specified version(s).
+ snmp_groups:
+ - group_name: Group1
+ version: v1
+ read_view: group_read_1
+ write_view: group_write_1
+ notify_view: group_notify_1
+ - group_name: Group2
+ version: v3
+ read_view: group_read_2
+ write_view: group_write_2
+ notify_view: group_notify_2
+ authentication: priv
+ - VerifySnmpHostLogging:
+ # Verifies SNMP logging configurations.
+ hosts:
+ - hostname: 192.168.1.100
+ vrf: default
+ - hostname: 192.168.1.103
+ vrf: MGMT
- VerifySnmpIPv4Acl:
# Verifies if the SNMP agent has IPv4 ACL(s) configured.
number: 3
@@ -723,14 +864,44 @@ anta.tests.snmp:
- VerifySnmpLocation:
# Verifies the SNMP location of a device.
location: New York
+ - VerifySnmpNotificationHost:
+ # Verifies the SNMP notification host(s) (SNMP manager) configurations.
+ notification_hosts:
+ - hostname: spine
+ vrf: default
+ notification_type: trap
+ version: v1
+ udp_port: 162
+ community_string: public
+ - hostname: 192.168.1.100
+ vrf: default
+ notification_type: trap
+ version: v3
+ udp_port: 162
+ user: public
- VerifySnmpPDUCounters:
# Verifies the SNMP PDU counters.
pdus:
- outTrapPdus
- inGetNextPdus
+ - VerifySnmpSourceInterface:
+ # Verifies SNMP source interfaces.
+ interfaces:
+ - interface: Ethernet1
+ vrf: default
+ - interface: Management0
+ vrf: MGMT
- VerifySnmpStatus:
# Verifies if the SNMP agent is enabled.
vrf: default
+ - VerifySnmpUser:
+ # Verifies the SNMP user configurations.
+ snmp_users:
+ - username: test
+ group_name: test_group
+ version: v3
+ auth_type: MD5
+ priv_type: AES-128
anta.tests.software:
- VerifyEOSExtensions:
# Verifies that all EOS extensions installed on the device are enabled for boot persistence.
@@ -749,6 +920,11 @@ anta.tests.stp:
# Verifies there is no STP blocked ports.
- VerifySTPCounters:
# Verifies there is no errors in STP BPDU packets.
+ - VerifySTPDisabledVlans:
+ # Verifies the STP disabled VLAN(s).
+ vlans:
+ - 6
+ - 4094
- VerifySTPForwardingPorts:
# Verifies that all interfaces are forwarding for a provided list of VLAN(s).
vlans:
@@ -799,6 +975,8 @@ anta.tests.system:
# Verifies there are no core dump files.
- VerifyFileSystemUtilization:
# Verifies that no partition is utilizing more than 75% of its disk space.
+ - VerifyMaintenance:
+ # Verifies that the device is not currently under or entering maintenance.
- VerifyMemoryUtilization:
# Verifies whether the memory utilization is below 75%.
- VerifyNTP:
@@ -813,12 +991,22 @@ anta.tests.system:
stratum: 2
- server_address: 3.3.3.3
stratum: 2
+ - VerifyNTPAssociations:
+ ntp_pool:
+ server_addresses: [1.1.1.1, 2.2.2.2]
+ preferred_stratum_range: [1,3]
- VerifyReloadCause:
# Verifies the last reload cause of the device.
- VerifyUptime:
# Verifies the device uptime.
minimum: 86400
anta.tests.vlan:
+ - VerifyDynamicVlanSource:
+ # Verifies dynamic VLAN allocation for specified VLAN sources.
+ sources:
+ - evpn
+ - mlagsync
+ strict: False
- VerifyVlanInternalPolicy:
# Verifies the VLAN internal allocation policy and the range of VLANs.
policy: ascending
diff --git a/mkdocs.yml b/mkdocs.yml
index 9c05fb9..5b3c6dc 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -101,12 +101,15 @@ plugins:
show_root_heading: true
show_root_full_path: false
show_signature_annotations: true
- # sadly symbols are for insiders only
- # https://mkdocstrings.github.io/python/usage/configuration/headings/#show_symbol_type_toc
- # show_symbol_type_heading: true
- # show_symbol_type_toc: true
+ show_symbol_type_heading: true
+ show_symbol_type_toc: true
+ signature_crossrefs: true
# default filters here
- filters: ["!^_[^_]"]
+ filters: ["!^_"]
+ extensions:
+ - griffe_warnings_deprecated:
+ kind: danger
+ title: Deprecated
- search:
lang: en
@@ -143,6 +146,7 @@ markdown_extensions:
- pymdownx.smartsymbols
- pymdownx.snippets:
base_path:
+ - docs
- docs/snippets
- examples
- pymdownx.superfences:
@@ -170,71 +174,71 @@ nav:
- Installation: requirements-and-installation.md
- Inventory and Test catalog: usage-inventory-catalog.md
- ANTA CLI:
- - Overview: cli/overview.md
- - NRFU: cli/nrfu.md
- - Execute commands: cli/exec.md
- - Inventory from CVP: cli/inv-from-cvp.md
- - Inventory from Ansible: cli/inv-from-ansible.md
- - Get Inventory Information: cli/get-inventory-information.md
- - Get Tests Information: cli/get-tests.md
- - Check commands: cli/check.md
- - Debug commands: cli/debug.md
- - Tag Management: cli/tag-management.md
+ - Overview: cli/overview.md
+ - NRFU: cli/nrfu.md
+ - Execute commands: cli/exec.md
+ - Inventory from CVP: cli/inv-from-cvp.md
+ - Inventory from Ansible: cli/inv-from-ansible.md
+ - Get Inventory Information: cli/get-inventory-information.md
+ - Get Tests Information: cli/get-tests.md
+ - Check commands: cli/check.md
+ - Debug commands: cli/debug.md
+ - Tag Management: cli/tag-management.md
- Advanced Usages:
- - Caching in ANTA: advanced_usages/caching.md
- - Developing ANTA tests: advanced_usages/custom-tests.md
- - ANTA as a Python Library: advanced_usages/as-python-lib.md
+ - Caching in ANTA: advanced_usages/caching.md
+ - Developing ANTA tests: advanced_usages/custom-tests.md
+ - ANTA as a Python Library: advanced_usages/as-python-lib.md
- Tests Documentation:
- - Overview: api/tests.md
- - AAA: api/tests.aaa.md
- - Adaptive Virtual Topology: api/tests.avt.md
- - BFD: api/tests.bfd.md
- - Configuration: api/tests.configuration.md
- - Connectivity: api/tests.connectivity.md
- - CVX: api/tests.cvx.md
- - Field Notices: api/tests.field_notices.md
- - Flow Tracking: api/tests.flow_tracking.md
- - GreenT: api/tests.greent.md
- - Hardware: api/tests.hardware.md
- - Interfaces: api/tests.interfaces.md
- - LANZ: api/tests.lanz.md
- - Logging: api/tests.logging.md
- - MLAG: api/tests.mlag.md
- - Multicast: api/tests.multicast.md
- - Profiles: api/tests.profiles.md
- - PTP: api/tests.ptp.md
- - Router Path Selection: api/tests.path_selection.md
- - Routing:
- - Generic: api/tests.routing.generic.md
- - BGP: api/tests.routing.bgp.md
- - OSPF: api/tests.routing.ospf.md
- - ISIS: api/tests.routing.isis.md
- - Security: api/tests.security.md
- - Services: api/tests.services.md
- - SNMP: api/tests.snmp.md
- - STP: api/tests.stp.md
- - STUN: api/tests.stun.md
- - Software: api/tests.software.md
- - System: api/tests.system.md
- - VXLAN: api/tests.vxlan.md
- - VLAN: api/tests.vlan.md
+ - Overview: api/tests.md
+ - AAA: api/tests/aaa.md
+ - Adaptive Virtual Topology: api/tests/avt.md
+ - BFD: api/tests/bfd.md
+ - Configuration: api/tests/configuration.md
+ - Connectivity: api/tests/connectivity.md
+ - CVX: api/tests/cvx.md
+ - Field Notices: api/tests/field_notices.md
+ - Flow Tracking: api/tests/flow_tracking.md
+ - GreenT: api/tests/greent.md
+ - Hardware: api/tests/hardware.md
+ - Interfaces: api/tests/interfaces.md
+ - LANZ: api/tests/lanz.md
+ - Logging: api/tests/logging.md
+ - MLAG: api/tests/mlag.md
+ - Multicast: api/tests/multicast.md
+ - Profiles: api/tests/profiles.md
+ - PTP: api/tests/ptp.md
+ - Router Path Selection: api/tests/path_selection.md
+ - Routing:
+ - Generic: api/tests/routing.generic.md
+ - BGP: api/tests/routing.bgp.md
+ - OSPF: api/tests/routing.ospf.md
+ - ISIS: api/tests/routing.isis.md
+ - Security: api/tests/security.md
+ - Services: api/tests/services.md
+ - SNMP: api/tests/snmp.md
+ - STP: api/tests/stp.md
+ - STUN: api/tests/stun.md
+ - Software: api/tests/software.md
+ - System: api/tests/system.md
+ - VXLAN: api/tests/vxlan.md
+ - VLAN: api/tests/vlan.md
- API Documentation:
- - Device: api/device.md
- - Inventory:
- - Inventory module: api/inventory.md
- - Inventory models: api/inventory.models.input.md
- - Test Catalog: api/catalog.md
- - Test:
- - Test models: api/models.md
- - Input Types: api/types.md
- - Result Manager:
- - Result Manager module: api/result_manager.md
- - Result Manager models: api/result_manager_models.md
- - Reporter:
- - CSV reporter: api/csv_reporter.md
- - Markdown reporter: api/md_reporter.md
- - Other reporters: api/reporters.md
- - Runner: api/runner.md
+ - Class Diagram: api/class-diagram.md
+ - Device: api/device.md
+ - Inventory: api/inventory.md
+ - Catalog: api/catalog.md
+ - Commands: api/commands.md
+ - Tests:
+ - AntaTest: api/tests/anta_test.md
+ - Input Types: api/tests/types.md
+ - Tests Documentation: /api/tests/
+ - Result: api/result.md
+ - Reporters:
+ - Table: api/reporter/table.md
+ - Markdown: api/reporter/markdown.md
+ - CSV: api/reporter/csv.md
+ - Jinja: api/reporter/jinja.md
+ - Runner: api/runner.md
- Troubleshooting ANTA: troubleshooting.md
- Contributions: contribution.md
- FAQ: faq.md
diff --git a/pyproject.toml b/pyproject.toml
index 1e85b01..69a72a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "anta"
-version = "v1.2.0"
+version = "v1.3.0"
readme = "docs/README.md"
authors = [{ name = "Arista Networks ANTA maintainers", email = "anta-dev@arista.com" }]
maintainers = [
@@ -19,7 +19,6 @@ maintainers = [
description = "Arista Network Test Automation (ANTA) Framework"
license = { file = "LICENSE" }
dependencies = [
- "aiocache>=0.12.2",
"asyncssh>=2.16",
"cvprac>=1.3.1",
"eval-type-backport>=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed)
@@ -29,7 +28,8 @@ dependencies = [
"pydantic-extra-types>=2.3.0",
"PyYAML>=6.0",
"requests>=2.31.0",
- "rich>=13.5.2,<14"
+ "rich>=13.5.2,<14",
+ "typing_extensions>=4.12" # required for deprecated before Python 3.13
]
keywords = ["test", "anta", "Arista", "network", "automation", "networking", "devops", "netdevops"]
classifiers = [
@@ -60,7 +60,7 @@ cli = [
]
dev = [
"bumpver>=2023.1129",
- "codespell>=2.2.6,<2.4.0",
+ "codespell>=2.2.6,<2.5.0",
"mypy-extensions~=1.0",
"mypy~=1.10",
"pre-commit>=3.3.3",
@@ -75,7 +75,7 @@ dev = [
"pytest-metadata>=3.0.0",
"pytest>=7.4.0",
"respx>=0.22.0",
- "ruff>=0.5.4,<0.9.0",
+ "ruff>=0.5.4,<0.11.0",
"tox>=4.10.0,<5.0.0",
"types-PyYAML",
"types-pyOpenSSL",
@@ -86,6 +86,7 @@ dev = [
doc = [
"fontawesome_markdown>=0.2.6",
"griffe >=1.2.0",
+ "griffe-warnings-deprecated>=1.1.0",
"mike==2.1.3",
"mkdocs>=1.6.1",
"mkdocs-autorefs>=1.2.0",
@@ -120,7 +121,7 @@ namespaces = false
# Version
################################
[tool.bumpver]
-current_version = "1.2.0"
+current_version = "1.3.0"
version_pattern = "MAJOR.MINOR.PATCH"
commit_message = "bump: Version {old_version} -> {new_version}"
commit = true
@@ -143,7 +144,7 @@ plugins = [
]
# Comment below for better type checking
#follow_imports = "skip"
-# Make it false if we implement stubs using stubgen from mypy for aio-eapi, aiocache and cvprac
+# Make it false if we implement stubs using stubgen from mypy for asynceapi, cvprac
# and configure mypy_path to generated stubs e.g.: mypy_path = "./out"
ignore_missing_imports = true
warn_redundant_casts = true
@@ -270,12 +271,14 @@ commands =
ruff format . --check
pylint anta
pylint tests
+ pylint asynceapi
[testenv:type]
description = Check typing
commands =
mypy --config-file=pyproject.toml anta
mypy --config-file=pyproject.toml tests
+ mypy --config-file=pyproject.toml asynceapi
[testenv:clean]
description = Erase previous coverage reports
@@ -428,6 +431,12 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In
"C901", # TODO: test function is too complex, needs a refactor
"PLR0912" # Too many branches (15/12) (too-many-branches), needs a refactor
]
+"anta/tests/logging.py" = [
+ "A005", # TODO: Module `logging` shadows a Python standard-library module
+]
+"anta/input_models/logging.py" = [
+ "A005", # TODO: Module `logging` shadows a Python standard-library module
+]
"anta/decorators.py" = [
"ANN401", # Ok to use Any type hint in our decorators
]
diff --git a/sonar-project.properties b/sonar-project.properties
index 0abd967..0cc0d7c 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -3,7 +3,9 @@ sonar.organization=aristanetworks-1
# Path to sources
sonar.sources=anta/,asynceapi/
-#sonar.exclusions=
+
+# Exclude asynceapi unused modules for now
+sonar.exclusions=asynceapi/config_session.py
#sonar.inclusions=
# Path to tests
diff --git a/tests/__init__.py b/tests/__init__.py
index 0a2486a..7945787 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py
index 7714c95..0f081fe 100644
--- a/tests/benchmark/__init__.py
+++ b/tests/benchmark/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Benchmark tests for ANTA."""
diff --git a/tests/benchmark/conftest.py b/tests/benchmark/conftest.py
index 04ce54c..79930dc 100644
--- a/tests/benchmark/conftest.py
+++ b/tests/benchmark/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Fixtures for benchmarking ANTA."""
diff --git a/tests/benchmark/test_anta.py b/tests/benchmark/test_anta.py
index 7d1f21c..1daf7f3 100644
--- a/tests/benchmark/test_anta.py
+++ b/tests/benchmark/test_anta.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Benchmark tests for ANTA."""
@@ -47,7 +47,7 @@ def test_anta_dry_run(
if len(results.results) != len(inventory) * len(catalog.tests):
pytest.fail(f"Expected {len(inventory) * len(catalog.tests)} tests but got {len(results.results)}", pytrace=False)
- bench_info = "\n--- ANTA NRFU Dry-Run Benchmark Information ---\n" f"Test count: {len(results.results)}\n" "-----------------------------------------------"
+ bench_info = f"\n--- ANTA NRFU Dry-Run Benchmark Information ---\nTest count: {len(results.results)}\n-----------------------------------------------"
logger.info(bench_info)
diff --git a/tests/benchmark/test_reporter.py b/tests/benchmark/test_reporter.py
index ea74fb5..c6d0b29 100644
--- a/tests/benchmark/test_reporter.py
+++ b/tests/benchmark/test_reporter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Benchmark tests for anta.reporter."""
diff --git a/tests/benchmark/test_runner.py b/tests/benchmark/test_runner.py
index a8639af..9aa54df 100644
--- a/tests/benchmark/test_runner.py
+++ b/tests/benchmark/test_runner.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Benchmark tests for anta.runner."""
diff --git a/tests/benchmark/utils.py b/tests/benchmark/utils.py
index 1017cfe..2a84430 100644
--- a/tests/benchmark/utils.py
+++ b/tests/benchmark/utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Utils for the ANTA benchmark tests."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 7858e40..362d9d0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files."""
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
index 864da68..eb44c55 100644
--- a/tests/data/__init__.py
+++ b/tests/data/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for unit tests."""
diff --git a/tests/data/invalid_inventory.yml b/tests/data/invalid_inventory.yml
new file mode 100644
index 0000000..5199a15
--- /dev/null
+++ b/tests/data/invalid_inventory.yml
@@ -0,0 +1,5 @@
+---
+anta_inventory:
+ - host: 172.20.20.101
+ name: DC1-SPINE1
+ tags: ["SPINE", "DC1"]
diff --git a/tests/data/syntax_error.py b/tests/data/syntax_error.py
index 051ef33..e3cec34 100644
--- a/tests/data/syntax_error.py
+++ b/tests/data/syntax_error.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
# pylint: skip-file
diff --git a/tests/data/test_inventory_large.json b/tests/data/test_inventory_large.json
new file mode 100644
index 0000000..876d940
--- /dev/null
+++ b/tests/data/test_inventory_large.json
@@ -0,0 +1,207 @@
+{
+ "anta_inventory": {
+ {
+ "hosts": [
+ {
+ "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",
+ "host": "localhost"
+ }
+ ]
+ }
+}
diff --git a/tests/data/test_inventory_medium.json b/tests/data/test_inventory_medium.json
new file mode 100644
index 0000000..5c4f147
--- /dev/null
+++ b/tests/data/test_inventory_medium.json
@@ -0,0 +1,30 @@
+{
+ "anta_inventory": {
+ "hosts": [
+ {
+ "name": "spine1",
+ "host": "localhost"
+ },
+ {
+ "name": "spine2",
+ "host": "localhost"
+ },
+ {
+ "name": "leaf1a",
+ "host": "localhost"
+ },
+ {
+ "name": "leaf1b",
+ "host": "localhost"
+ },
+ {
+ "name": "leaf2a",
+ "host": "localhost"
+ },
+ {
+ "name": "leaf2b",
+ "host": "localhost"
+ }
+ ]
+ }
+}
diff --git a/tests/data/test_inventory_with_tags.json b/tests/data/test_inventory_with_tags.json
new file mode 100644
index 0000000..0f0aa7c
--- /dev/null
+++ b/tests/data/test_inventory_with_tags.json
@@ -0,0 +1,22 @@
+{
+ "anta_inventory": {
+ "hosts": [
+ {
+ "name": "leaf1",
+ "host": "leaf1.anta.arista.com",
+ "tags": ["dc1", "leaf"]
+ },
+ {
+ "name": "leaf2",
+ "host": "leaf2.anta.arista.com",
+ "tags": ["leaf"]
+ },
+ {
+ "name": "spine1",
+ "host": "spine1.anta.arista.com",
+ "tags": ["spine"],
+ "disable_cache": true
+ }
+ ]
+ }
+}
diff --git a/tests/data/test_inventory_with_tags.yml b/tests/data/test_inventory_with_tags.yml
index 16a9df4..a3eb0c0 100644
--- a/tests/data/test_inventory_with_tags.yml
+++ b/tests/data/test_inventory_with_tags.yml
@@ -3,10 +3,11 @@ anta_inventory:
hosts:
- name: leaf1
host: leaf1.anta.arista.com
- tags: ["leaf", "dc1"]
+ tags: ["dc1", "leaf"]
- name: leaf2
host: leaf2.anta.arista.com
tags: ["leaf"]
- name: spine1
host: spine1.anta.arista.com
tags: ["spine"]
+ disable_cache: true
diff --git a/tests/data/test_md_report.md b/tests/data/test_md_report.md
index 9360dbc..1b1acd1 100644
--- a/tests/data/test_md_report.md
+++ b/tests/data/test_md_report.md
@@ -15,65 +15,78 @@
| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error |
| ----------- | ------------------- | ------------------- | ------------------- | ------------------|
-| 30 | 7 | 2 | 19 | 2 |
+| 30 | 4 | 9 | 15 | 2 |
### Summary Totals Device Under Test
| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed |
| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------|
-| DC1-SPINE1 | 15 | 2 | 2 | 10 | 1 | MLAG, VXLAN | AAA, BFD, BGP, Connectivity, Routing, SNMP, STP, Services, Software, System |
-| DC1-LEAF1A | 15 | 5 | 0 | 9 | 1 | - | AAA, BFD, BGP, Connectivity, SNMP, STP, Services, Software, System |
+| s1-spine1 | 30 | 4 | 9 | 15 | 2 | AVT, Field Notices, Hardware, ISIS, LANZ, OSPF, PTP, Path-Selection, Profiles | AAA, BFD, BGP, Connectivity, Cvx, Interfaces, Logging, MLAG, SNMP, STUN, Security, Services, Software, System, VLAN |
### Summary Totals Per Category
| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error |
| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- |
-| AAA | 2 | 0 | 0 | 2 | 0 |
-| BFD | 2 | 0 | 0 | 2 | 0 |
-| BGP | 2 | 0 | 0 | 2 | 0 |
-| Connectivity | 4 | 0 | 0 | 2 | 2 |
-| Interfaces | 2 | 2 | 0 | 0 | 0 |
-| MLAG | 2 | 1 | 1 | 0 | 0 |
-| Routing | 2 | 1 | 0 | 1 | 0 |
-| SNMP | 2 | 0 | 0 | 2 | 0 |
-| STP | 2 | 0 | 0 | 2 | 0 |
-| Security | 2 | 2 | 0 | 0 | 0 |
-| Services | 2 | 0 | 0 | 2 | 0 |
-| Software | 2 | 0 | 0 | 2 | 0 |
-| System | 2 | 0 | 0 | 2 | 0 |
-| VXLAN | 2 | 1 | 1 | 0 | 0 |
+| AAA | 1 | 0 | 0 | 1 | 0 |
+| AVT | 1 | 0 | 1 | 0 | 0 |
+| BFD | 1 | 0 | 0 | 1 | 0 |
+| BGP | 1 | 0 | 0 | 0 | 1 |
+| Configuration | 1 | 1 | 0 | 0 | 0 |
+| Connectivity | 1 | 0 | 0 | 1 | 0 |
+| Cvx | 1 | 0 | 0 | 0 | 1 |
+| Field Notices | 1 | 0 | 1 | 0 | 0 |
+| Hardware | 1 | 0 | 1 | 0 | 0 |
+| Interfaces | 1 | 0 | 0 | 1 | 0 |
+| ISIS | 1 | 0 | 1 | 0 | 0 |
+| LANZ | 1 | 0 | 1 | 0 | 0 |
+| Logging | 1 | 0 | 0 | 1 | 0 |
+| MLAG | 1 | 0 | 0 | 1 | 0 |
+| OSPF | 1 | 0 | 1 | 0 | 0 |
+| Path-Selection | 1 | 0 | 1 | 0 | 0 |
+| Profiles | 1 | 0 | 1 | 0 | 0 |
+| PTP | 1 | 0 | 1 | 0 | 0 |
+| Routing | 1 | 1 | 0 | 0 | 0 |
+| Security | 2 | 0 | 0 | 2 | 0 |
+| Services | 1 | 0 | 0 | 1 | 0 |
+| SNMP | 1 | 0 | 0 | 1 | 0 |
+| Software | 1 | 0 | 0 | 1 | 0 |
+| STP | 1 | 1 | 0 | 0 | 0 |
+| STUN | 2 | 0 | 0 | 2 | 0 |
+| System | 1 | 0 | 0 | 1 | 0 |
+| VLAN | 1 | 0 | 0 | 1 | 0 |
+| VXLAN | 1 | 1 | 0 | 0 | 0 |
## Test Results
| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages |
| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- |
-| DC1-LEAF1A | BFD | VerifyBFDSpecificPeers | Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF. | - | failure | Following BFD peers are not configured, status is not up or remote disc is zero: {'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}} |
-| DC1-LEAF1A | BGP | VerifyBGPPeerCount | Verifies the count of BGP peers. | - | failure | Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 2, Actual: 1'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Expected: 3, Actual: 0'}}] |
-| DC1-LEAF1A | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | device is running version "4.31.1F-34554157.4311F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F'] |
-| DC1-LEAF1A | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Expected 's1-spine1' as the hostname, but found 'DC1-LEAF1A' instead. |
-| DC1-LEAF1A | Interfaces | VerifyInterfaceUtilization | Verifies that the utilization of interfaces is below a certain threshold. | - | success | - |
-| DC1-LEAF1A | Connectivity | VerifyLLDPNeighbors | Verifies that the provided LLDP neighbors are connected properly. | - | failure | Wrong LLDP neighbor(s) on port(s): Ethernet1 DC1-SPINE1_Ethernet1 Ethernet2 DC1-SPINE2_Ethernet1 Port(s) not configured: Ethernet7 |
-| DC1-LEAF1A | MLAG | VerifyMlagStatus | Verifies the health status of the MLAG configuration. | - | success | - |
-| DC1-LEAF1A | System | VerifyNTP | Verifies if NTP is synchronised. | - | failure | The device is not synchronized with the configured NTP server(s): 'NTP is disabled.' |
-| DC1-LEAF1A | Connectivity | VerifyReachability | Test the network reachability to one or many destination IP(s). | - | error | ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1 |
-| DC1-LEAF1A | Routing | VerifyRoutingTableEntry | Verifies that the provided routes are present in the routing table of a specified VRF. | - | success | - |
-| DC1-LEAF1A | STP | VerifySTPMode | Verifies the configured STP mode for a provided list of VLAN(s). | - | failure | Wrong STP mode configured for the following VLAN(s): [10, 20] |
-| DC1-LEAF1A | SNMP | VerifySnmpStatus | Verifies if the SNMP agent is enabled. | - | failure | SNMP agent disabled in vrf default |
-| DC1-LEAF1A | AAA | VerifyTacacsSourceIntf | Verifies TACACS source-interface for a specified VRF. | - | failure | Source-interface Management0 is not configured in VRF default |
-| DC1-LEAF1A | Security | VerifyTelnetStatus | Verifies if Telnet is disabled in the default VRF. | - | success | - |
-| DC1-LEAF1A | VXLAN | VerifyVxlan1Interface | Verifies the Vxlan1 interface status. | - | success | - |
-| DC1-SPINE1 | BFD | VerifyBFDSpecificPeers | Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF. | - | failure | Following BFD peers are not configured, status is not up or remote disc is zero: {'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}} |
-| DC1-SPINE1 | BGP | VerifyBGPPeerCount | Verifies the count of BGP peers. | - | failure | Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Not Configured', 'default': 'Expected: 3, Actual: 4'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, {'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 4'}}] |
-| DC1-SPINE1 | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | device is running version "4.31.1F-34554157.4311F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F'] |
-| DC1-SPINE1 | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Expected 's1-spine1' as the hostname, but found 'DC1-SPINE1' instead. |
-| DC1-SPINE1 | Interfaces | VerifyInterfaceUtilization | Verifies that the utilization of interfaces is below a certain threshold. | - | success | - |
-| DC1-SPINE1 | Connectivity | VerifyLLDPNeighbors | Verifies that the provided LLDP neighbors are connected properly. | - | failure | Wrong LLDP neighbor(s) on port(s): Ethernet1 DC1-LEAF1A_Ethernet1 Ethernet2 DC1-LEAF1B_Ethernet1 Port(s) not configured: Ethernet7 |
-| DC1-SPINE1 | MLAG | VerifyMlagStatus | Verifies the health status of the MLAG configuration. | - | skipped | MLAG is disabled |
-| DC1-SPINE1 | System | VerifyNTP | Verifies if NTP is synchronised. | - | failure | The device is not synchronized with the configured NTP server(s): 'NTP is disabled.' |
-| DC1-SPINE1 | Connectivity | VerifyReachability | Test the network reachability to one or many destination IP(s). | - | error | ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1 |
-| DC1-SPINE1 | Routing | VerifyRoutingTableEntry | Verifies that the provided routes are present in the routing table of a specified VRF. | - | failure | The following route(s) are missing from the routing table of VRF default: ['10.1.0.2'] |
-| DC1-SPINE1 | STP | VerifySTPMode | Verifies the configured STP mode for a provided list of VLAN(s). | - | failure | STP mode 'rapidPvst' not configured for the following VLAN(s): [10, 20] |
-| DC1-SPINE1 | SNMP | VerifySnmpStatus | Verifies if the SNMP agent is enabled. | - | failure | SNMP agent disabled in vrf default |
-| DC1-SPINE1 | AAA | VerifyTacacsSourceIntf | Verifies TACACS source-interface for a specified VRF. | - | failure | Source-interface Management0 is not configured in VRF default |
-| DC1-SPINE1 | Security | VerifyTelnetStatus | Verifies if Telnet is disabled in the default VRF. | - | success | - |
-| DC1-SPINE1 | VXLAN | VerifyVxlan1Interface | Verifies the Vxlan1 interface status. | - | skipped | Vxlan1 interface is not configured |
+| s1-spine1 | AAA | VerifyAcctConsoleMethods | Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x). | - | failure | AAA console accounting is not configured for commands, exec, system, dot1x |
+| s1-spine1 | AVT | VerifyAVTPathHealth | Verifies the status of all AVT paths for all VRFs. | - | skipped | VerifyAVTPathHealth test is not supported on cEOSLab. |
+| s1-spine1 | BFD | VerifyBFDPeersHealth | Verifies the health of IPv4 BFD peers across all VRFs. | - | failure | No IPv4 BFD peers are configured for any VRF. |
+| s1-spine1 | BGP | VerifyBGPAdvCommunities | Verifies that advertised communities are standard, extended and large for BGP IPv4 peer(s). | - | error | show bgp neighbors vrf all has failed: The command is only supported in the multi-agent routing protocol model., The command is only supported in the multi-agent routing protocol model., The command is only supported in the multi-agent routing protocol model., The command is only supported in the multi-agent routing protocol model. |
+| s1-spine1 | Configuration | VerifyRunningConfigDiffs | Verifies there is no difference between the running-config and the startup-config. | - | success | - |
+| s1-spine1 | Connectivity | VerifyLLDPNeighbors | Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors. | - | failure | Port: Ethernet1 Neighbor: DC1-SPINE1 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: spine1-dc1.fun.aristanetworks.com/Ethernet3
Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: spine2-dc1.fun.aristanetworks.com/Ethernet3 |
+| s1-spine1 | Cvx | VerifyActiveCVXConnections | Verifies the number of active CVX Connections. | - | error | show cvx connections brief has failed: Unavailable command (controller not ready) (at token 2: 'connections') |
+| s1-spine1 | Field Notices | VerifyFieldNotice44Resolution | Verifies that the device is using the correct Aboot version per FN0044. | - | skipped | VerifyFieldNotice44Resolution test is not supported on cEOSLab. |
+| s1-spine1 | Hardware | VerifyTemperature | Verifies if the device temperature is within acceptable limits. | - | skipped | VerifyTemperature test is not supported on cEOSLab. |
+| s1-spine1 | Interfaces | VerifyIPProxyARP | Verifies if Proxy ARP is enabled. | - | failure | Interface: Ethernet1 - Proxy-ARP disabled
Interface: Ethernet2 - Proxy-ARP disabled |
+| s1-spine1 | ISIS | VerifyISISNeighborState | Verifies the health of IS-IS neighbors. | - | skipped | IS-IS not configured |
+| s1-spine1 | LANZ | VerifyLANZ | Verifies if LANZ is enabled. | - | skipped | VerifyLANZ test is not supported on cEOSLab. |
+| s1-spine1 | Logging | VerifyLoggingHosts | Verifies logging hosts (syslog servers) for a specified VRF. | - | failure | Syslog servers 1.1.1.1, 2.2.2.2 are not configured in VRF default |
+| s1-spine1 | MLAG | VerifyMlagDualPrimary | Verifies the MLAG dual-primary detection parameters. | - | failure | Dual-primary detection is disabled |
+| s1-spine1 | OSPF | VerifyOSPFMaxLSA | Verifies all OSPF instances did not cross the maximum LSA threshold. | - | skipped | No OSPF instance found. |
+| s1-spine1 | Path-Selection | VerifyPathsHealth | Verifies the path and telemetry state of all paths under router path-selection. | - | skipped | VerifyPathsHealth test is not supported on cEOSLab. |
+| s1-spine1 | Profiles | VerifyTcamProfile | Verifies the device TCAM profile. | - | skipped | VerifyTcamProfile test is not supported on cEOSLab. |
+| s1-spine1 | PTP | VerifyPtpGMStatus | Verifies that the device is locked to a valid PTP Grandmaster. | - | skipped | VerifyPtpGMStatus test is not supported on cEOSLab. |
+| s1-spine1 | Routing | VerifyIPv4RouteNextHops | Verifies the next-hops of the IPv4 prefixes. | - | success | - |
+| s1-spine1 | Security | VerifyBannerLogin | Verifies the login banner of a device. | - | failure | Expected '# 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.
' as the login banner, but found '' instead. |
+| s1-spine1 | Security | VerifyBannerMotd | Verifies the motd banner of a device. | - | failure | Expected '# 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.
' as the motd banner, but found '' instead. |
+| s1-spine1 | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Incorrect Hostname - Expected: s1-spine1 Actual: leaf1-dc1 |
+| s1-spine1 | SNMP | VerifySnmpContact | Verifies the SNMP contact of a device. | - | failure | SNMP contact is not configured. |
+| s1-spine1 | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | EOS version mismatch - Actual: 4.31.0F-33804048.4310F (engineering build) not in Expected: 4.25.4M, 4.26.1F |
+| s1-spine1 | STP | VerifySTPBlockedPorts | Verifies there is no STP blocked ports. | - | success | - |
+| s1-spine1 | STUN | VerifyStunClient | (Deprecated) Verifies the translation for a source address on a STUN client. | - | failure | Client 172.18.3.2 Port: 4500 - STUN client translation not found. |
+| s1-spine1 | STUN | VerifyStunClientTranslation | Verifies the translation for a source address on a STUN client. | - | failure | Client 172.18.3.2 Port: 4500 - STUN client translation not found.
Client 100.64.3.2 Port: 4500 - STUN client translation not found. |
+| s1-spine1 | System | VerifyNTPAssociations | Verifies the Network Time Protocol (NTP) associations. | - | failure | NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Not configured
NTP Server: 2.2.2.2 Preferred: False Stratum: 2 - Not configured
NTP Server: 3.3.3.3 Preferred: False Stratum: 2 - Not configured |
+| s1-spine1 | VLAN | VerifyDynamicVlanSource | Verifies dynamic VLAN allocation for specified VLAN sources. | - | failure | Dynamic VLAN source(s) exist but have no VLANs allocated: mlagsync |
+| s1-spine1 | VXLAN | VerifyVxlan1ConnSettings | Verifies the interface vxlan1 source interface and UDP port. | - | success | - |
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..923f485
--- /dev/null
+++ b/tests/integration/__init__.py
@@ -0,0 +1,7 @@
+# Copyright (c) 2024-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Integration tests for ANTA.
+
+In particular this module test the examples/*.py scripts to make sure they are still working.
+"""
diff --git a/tests/integration/data/device1-catalog.yml b/tests/integration/data/device1-catalog.yml
new file mode 100644
index 0000000..c5b55b0
--- /dev/null
+++ b/tests/integration/data/device1-catalog.yml
@@ -0,0 +1,5 @@
+---
+anta.tests.software:
+ - VerifyEOSVersion:
+ versions:
+ - 4.31.1F
diff --git a/tests/integration/data/device2-catalog.yml b/tests/integration/data/device2-catalog.yml
new file mode 100644
index 0000000..8e3bc49
--- /dev/null
+++ b/tests/integration/data/device2-catalog.yml
@@ -0,0 +1,5 @@
+---
+anta.tests.software:
+ - VerifyEOSVersion:
+ versions:
+ - 4.31.2F
diff --git a/tests/integration/data/device3-catalog.yml b/tests/integration/data/device3-catalog.yml
new file mode 100644
index 0000000..3fe3099
--- /dev/null
+++ b/tests/integration/data/device3-catalog.yml
@@ -0,0 +1,5 @@
+---
+anta.tests.software:
+ - VerifyEOSVersion:
+ versions:
+ - 4.31.3F
diff --git a/tests/integration/test_merge_catalogs.py b/tests/integration/test_merge_catalogs.py
new file mode 100644
index 0000000..5c63803
--- /dev/null
+++ b/tests/integration/test_merge_catalogs.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2024-2025 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 examples/merge_catalogs.py script."""
+
+from __future__ import annotations
+
+import runpy
+from pathlib import Path
+
+from anta.catalog import AntaCatalog
+
+DATA = Path(__file__).parent / "data"
+MERGE_CATALOGS_PATH = Path(__file__).parents[2] / "examples/merge_catalogs.py"
+
+
+def test_merge_catalogs() -> None:
+ """Test merge_catalogs script."""
+ # Adding symlink to match the script data
+ intended_path = Path.cwd() / "intended"
+ intended_path.mkdir(exist_ok=True)
+ intended_catalogs_path = intended_path / "test_catalogs/"
+ intended_catalogs_path.symlink_to(DATA, target_is_directory=True)
+
+ try:
+ # Run the script
+ runpy.run_path(str(MERGE_CATALOGS_PATH), run_name="__main__")
+ # Assert that the created file exist and is a combination of the inputs
+ output_catalog = Path("anta-catalog.yml")
+ assert output_catalog.exists()
+
+ total_tests = sum(len(AntaCatalog.parse(catalog_file).tests) for catalog_file in DATA.rglob("*-catalog.yml"))
+
+ assert total_tests == len(AntaCatalog.parse(output_catalog).tests)
+
+ finally:
+ # Cleanup
+ output_catalog.unlink()
+ intended_catalogs_path.unlink()
+ intended_path.rmdir()
diff --git a/tests/integration/test_parse_anta_inventory_file.py b/tests/integration/test_parse_anta_inventory_file.py
new file mode 100644
index 0000000..8305d9b
--- /dev/null
+++ b/tests/integration/test_parse_anta_inventory_file.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2024-2025 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 examples/parse_anta_inventory_file.py script."""
+
+from __future__ import annotations
+
+import runpy
+from pathlib import Path
+
+import pytest
+from yaml import safe_dump
+
+from anta.inventory import AntaInventory
+
+DATA = Path(__file__).parent / "data"
+PARSE_ANTA_INVENTORY_FILE_PATH = Path(__file__).parents[2] / "examples/parse_anta_inventory_file.py"
+
+
+@pytest.mark.parametrize("inventory", [{"count": 3}], indirect=["inventory"])
+def test_parse_anta_inventory_file(capsys: pytest.CaptureFixture[str], inventory: AntaInventory) -> None:
+ """Test parse_anta_inventory_file script."""
+ # Create the inventory.yaml file expected by the script
+ # TODO: 2.0.0 this is horrendous - need to align how to dump things properly
+ inventory_path = Path.cwd() / "inventory.yaml"
+ yaml_data = {AntaInventory.INVENTORY_ROOT_KEY: inventory.dump().model_dump()}
+ with inventory_path.open("w") as f:
+ safe_dump(yaml_data, f)
+
+ try:
+ # Run the script
+ runpy.run_path(str(PARSE_ANTA_INVENTORY_FILE_PATH), run_name="__main__")
+ captured = capsys.readouterr()
+ assert "Device device-0 is online" in captured.out
+ assert "Device device-1 is online" in captured.out
+ assert "Device device-2 is online" in captured.out
+
+ finally:
+ # Cleanup
+ inventory_path.unlink()
diff --git a/tests/integration/test_run_eos_commands.py b/tests/integration/test_run_eos_commands.py
new file mode 100644
index 0000000..3022a78
--- /dev/null
+++ b/tests/integration/test_run_eos_commands.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2024-2025 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 examples/run_eos_commands.py script."""
+
+from __future__ import annotations
+
+import runpy
+from pathlib import Path
+
+import pytest
+import respx
+from yaml import safe_dump
+
+from anta.inventory import AntaInventory
+
+DATA = Path(__file__).parent / "data"
+RUN_EOS_COMMANDS_PATH = Path(__file__).parents[2] / "examples/run_eos_commands.py"
+
+
+@pytest.mark.parametrize("inventory", [{"count": 3}], indirect=["inventory"])
+def test_run_eos_commands(capsys: pytest.CaptureFixture[str], inventory: AntaInventory) -> None:
+ """Test run_eos_commands script."""
+ # Create the inventory.yaml file expected by the script
+ # TODO: 2.0.0 this is horrendous - need to align how to dump things properly
+ inventory_path = Path.cwd() / "inventory.yaml"
+ yaml_data = {AntaInventory.INVENTORY_ROOT_KEY: inventory.dump().model_dump()}
+ with inventory_path.open("w") as f:
+ safe_dump(yaml_data, f)
+
+ try:
+ respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show ip bgp summary").respond(
+ json={
+ "result": [
+ {
+ "mocked": "mock",
+ }
+ ],
+ }
+ )
+ # Run the script
+ runpy.run_path(str(RUN_EOS_COMMANDS_PATH), run_name="__main__")
+ captured = capsys.readouterr()
+ # This is only to make sure we get the expected output - what counts is that the script runs.
+ assert "'device-0': [AntaCommand(command='show version', version='latest', revision=None, ofmt='json', output={'modelName': 'pytest'}," in captured.out
+ assert "'device-1': [AntaCommand(command='show version', version='latest', revision=None, ofmt='json', output={'modelName': 'pytest'}," in captured.out
+ assert "'device-2': [AntaCommand(command='show version', version='latest', revision=None, ofmt='json', output={'modelName': 'pytest'}," in captured.out
+ assert "AntaCommand(command='show ip bgp summary', version='latest', revision=None, ofmt='json', output={'mocked': 'mock'}, " in captured.out
+
+ finally:
+ # Cleanup
+ inventory_path.unlink()
diff --git a/tests/units/__init__.py b/tests/units/__init__.py
index 6b2d4ac..1ef6d0e 100644
--- a/tests/units/__init__.py
+++ b/tests/units/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Unit tests for ANTA."""
diff --git a/tests/units/anta_tests/README.md b/tests/units/anta_tests/README.md
index 6e4c5f0..fa88b8f 100644
--- a/tests/units/anta_tests/README.md
+++ b/tests/units/anta_tests/README.md
@@ -1,5 +1,5 @@
diff --git a/tests/units/anta_tests/__init__.py b/tests/units/anta_tests/__init__.py
index bfebc6d..9bfb5f8 100644
--- a/tests/units/anta_tests/__init__.py
+++ b/tests/units/anta_tests/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 module."""
diff --git a/tests/units/anta_tests/conftest.py b/tests/units/anta_tests/conftest.py
index 5e0c11b..3296430 100644
--- a/tests/units/anta_tests/conftest.py
+++ b/tests/units/anta_tests/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files."""
diff --git a/tests/units/anta_tests/routing/__init__.py b/tests/units/anta_tests/routing/__init__.py
index aef1274..a0682be 100644
--- a/tests/units/anta_tests/routing/__init__.py
+++ b/tests/units/anta_tests/routing/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 for anta.tests.routing submodule."""
diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py
index 59a6719..46115e2 100644
--- a/tests/units/anta_tests/routing/test_bgp.py
+++ b/tests/units/anta_tests/routing/test_bgp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.bgp.py."""
@@ -14,16 +14,25 @@ from anta.input_models.routing.bgp import BgpAddressFamily
from anta.tests.routing.bgp import (
VerifyBGPAdvCommunities,
VerifyBGPExchangedRoutes,
+ VerifyBGPNlriAcceptance,
VerifyBGPPeerASNCap,
VerifyBGPPeerCount,
VerifyBGPPeerDropStats,
+ VerifyBGPPeerGroup,
VerifyBGPPeerMD5Auth,
VerifyBGPPeerMPCaps,
VerifyBGPPeerRouteLimit,
VerifyBGPPeerRouteRefreshCap,
+ VerifyBGPPeerSession,
+ VerifyBGPPeerSessionRibd,
VerifyBGPPeersHealth,
+ VerifyBGPPeersHealthRibd,
+ VerifyBGPPeerTtlMultiHops,
VerifyBGPPeerUpdateErrors,
+ VerifyBGPRedistribution,
+ VerifyBGPRouteECMP,
VerifyBgpRouteMaps,
+ VerifyBGPRoutePaths,
VerifyBGPSpecificPeers,
VerifyBGPTimers,
VerifyEVPNType2Route,
@@ -297,7 +306,7 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: vpn-ipv4 - Expected: 2, Actual: 0",
+ "AFI: vpn-ipv4 - Peer count mismatch - Expected: 2 Actual: 0",
],
},
},
@@ -366,8 +375,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: evpn - Expected: 3, Actual: 2",
- "AFI: ipv4 SAFI: unicast VRF: DEV - Expected: 2, Actual: 1",
+ "AFI: evpn - Peer count mismatch - Expected: 3 Actual: 2",
+ "AFI: ipv4 SAFI: unicast VRF: DEV - Peer count mismatch - Expected: 2 Actual: 1",
],
},
},
@@ -415,9 +424,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: evpn - Expected: 2, Actual: 1",
- "AFI: ipv4 SAFI: unicast VRF: default - Expected: 2, Actual: 1",
- "AFI: ipv4 SAFI: unicast VRF: DEV - Expected: 2, Actual: 1",
+ "AFI: evpn - Peer count mismatch - Expected: 2 Actual: 1",
+ "AFI: ipv4 SAFI: unicast VRF: default - Peer count mismatch - Expected: 2 Actual: 1",
+ "AFI: ipv4 SAFI: unicast VRF: DEV - Peer count mismatch - Expected: 2 Actual: 1",
],
},
},
@@ -465,6 +474,54 @@ DATA: list[dict[str, Any]] = [
},
"expected": {"result": "success"},
},
+ {
+ "name": "success-min-established-time",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.12",
+ "state": "Established",
+ "establishedTime": 169883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ {
+ "peerAddress": "10.100.0.13",
+ "state": "Established",
+ "establishedTime": 169883,
+ "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ "DEV": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.12",
+ "state": "Established",
+ "establishedTime": 169883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "address_families": [
+ {"afi": "evpn"},
+ {"afi": "ipv4", "safi": "unicast", "vrf": "default"},
+ {"afi": "ipv4", "safi": "unicast", "vrf": "DEV"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
{
"name": "failure-vrf-not-configured",
"test": VerifyBGPPeersHealth,
@@ -561,10 +618,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session state is not established - State: Idle",
- "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session state is not established - State: Active",
- "AFI: path-selection Peer: 10.100.0.13 - Session state is not established - State: Idle",
- "AFI: link-state Peer: 10.100.0.14 - Session state is not established - State: Active",
+ "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Incorrect session state - Expected: Established Actual: Idle",
+ "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Incorrect session state - Expected: Established Actual: Active",
+ "AFI: path-selection Peer: 10.100.0.13 - Incorrect session state - Expected: Established Actual: Idle",
+ "AFI: link-state Peer: 10.100.0.14 - Incorrect session state - Expected: Established Actual: Active",
],
},
},
@@ -679,10 +736,67 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 2, OutQ: 4",
- "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 5, OutQ: 1",
- "AFI: path-selection Peer: 10.100.0.13 - Session has non-empty message queues - InQ: 1, OutQ: 1",
- "AFI: link-state Peer: 10.100.0.14 - Session has non-empty message queues - InQ: 3, OutQ: 2",
+ "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 2 OutQ: 4",
+ "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 5 OutQ: 1",
+ "AFI: path-selection Peer: 10.100.0.13 - Session has non-empty message queues - InQ: 1 OutQ: 1",
+ "AFI: link-state Peer: 10.100.0.14 - Session has non-empty message queues - InQ: 3 OutQ: 2",
+ ],
+ },
+ },
+ {
+ "name": "failure-min-established-time",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.12",
+ "state": "Established",
+ "establishedTime": 9883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ {
+ "peerAddress": "10.100.0.13",
+ "state": "Established",
+ "establishedTime": 9883,
+ "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ "DEV": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.12",
+ "state": "Established",
+ "establishedTime": 9883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "address_families": [
+ {"afi": "evpn"},
+ {"afi": "ipv4", "safi": "unicast", "vrf": "default"},
+ {"afi": "ipv4", "safi": "unicast", "vrf": "DEV"},
+ ],
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "AFI: evpn Peer: 10.100.0.13 - BGP session not established for the minimum required duration - Expected: 10000s Actual: 9883s",
+ "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - BGP session not established for the minimum required duration - "
+ "Expected: 10000s Actual: 9883s",
+ "AFI: ipv4 SAFI: unicast VRF: DEV Peer: 10.100.0.12 - BGP session not established for the minimum required duration - "
+ "Expected: 10000s Actual: 9883s",
],
},
},
@@ -730,6 +844,54 @@ DATA: list[dict[str, Any]] = [
},
"expected": {"result": "success"},
},
+ {
+ "name": "success-min-established-time",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.12",
+ "state": "Established",
+ "establishedTime": 169883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ {
+ "peerAddress": "10.100.0.13",
+ "state": "Established",
+ "establishedTime": 169883,
+ "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.14",
+ "state": "Established",
+ "establishedTime": 169883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]},
+ {"afi": "evpn", "peers": ["10.100.0.13"]},
+ {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
{
"name": "failure-peer-not-configured",
"test": VerifyBGPSpecificPeers,
@@ -835,8 +997,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session state is not established - State: Idle",
- "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Session state is not established - State: Idle",
+ "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Incorrect session state - Expected: Established Actual: Idle",
+ "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Incorrect session state - Expected: Established Actual: Idle",
],
},
},
@@ -964,8 +1126,65 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 3, OutQ: 3",
- "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Session has non-empty message queues - InQ: 2, OutQ: 2",
+ "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 3 OutQ: 3",
+ "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Session has non-empty message queues - InQ: 2 OutQ: 2",
+ ],
+ },
+ },
+ {
+ "name": "failure-min-established-time",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.12",
+ "state": "Established",
+ "establishedTime": 9883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ {
+ "peerAddress": "10.100.0.13",
+ "state": "Established",
+ "establishedTime": 9883,
+ "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.14",
+ "state": "Established",
+ "establishedTime": 9883,
+ "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}},
+ "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0},
+ },
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]},
+ {"afi": "evpn", "peers": ["10.100.0.13"]},
+ {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]},
+ ],
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - BGP session not established for the minimum required duration - "
+ "Expected: 10000s Actual: 9883s",
+ "AFI: evpn Peer: 10.100.0.13 - BGP session not established for the minimum required duration - Expected: 10000s Actual: 9883s",
+ "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - BGP session not established for the minimum required duration - "
+ "Expected: 10000s Actual: 9883s",
],
},
},
@@ -1104,6 +1323,139 @@ DATA: list[dict[str, Any]] = [
},
"expected": {"result": "success"},
},
+ {
+ "name": "success-advertised-route-validation-only",
+ "test": VerifyBGPExchangedRoutes,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ]
+ },
+ "192.0.254.5/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ]
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ]
+ },
+ "192.0.254.5/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ]
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": False,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": False,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": False,
+ "active": True,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": False,
+ "active": True,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.5/32", "192.0.254.3/32"],
+ },
+ {
+ "peer_address": "172.30.11.5",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.3/32", "192.0.254.5/32"],
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
{
"name": "failure-no-routes",
"test": VerifyBGPExchangedRoutes,
@@ -1311,13 +1663,154 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.3/32 - Valid: False, Active: True",
+ "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.3/32 - Valid: False Active: True",
"Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.51/32 - Not found",
"Peer: 172.30.11.1 VRF: default Received route: 192.0.254.31/32 - Not found",
- "Peer: 172.30.11.1 VRF: default Received route: 192.0.255.4/32 - Valid: False, Active: False",
+ "Peer: 172.30.11.1 VRF: default Received route: 192.0.255.4/32 - Valid: False Active: False",
"Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.31/32 - Not found",
- "Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.5/32 - Valid: False, Active: True",
- "Peer: 172.30.11.5 VRF: default Received route: 192.0.254.3/32 - Valid: True, Active: False",
+ "Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.5/32 - Valid: False Active: True",
+ "Peer: 172.30.11.5 VRF: default Received route: 192.0.254.3/32 - Valid: True Active: False",
+ "Peer: 172.30.11.5 VRF: default Received route: 192.0.255.41/32 - Not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-invalid-or-inactive-routes-as-per-given-input",
+ "test": VerifyBGPExchangedRoutes,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": False,
+ "active": True,
+ },
+ }
+ ]
+ },
+ "192.0.254.5/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": False,
+ },
+ }
+ ]
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ]
+ },
+ "192.0.254.5/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ]
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": False,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": False,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.3/32", "192.0.254.51/32"],
+ },
+ {
+ "peer_address": "172.30.11.5",
+ "vrf": "default",
+ "received_routes": ["192.0.254.3/32", "192.0.255.41/32"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.3/32 - Valid: False Active: True",
+ "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.51/32 - Not found",
+ "Peer: 172.30.11.5 VRF: default Received route: 192.0.254.3/32 - Valid: True Active: False",
"Peer: 172.30.11.5 VRF: default Received route: 192.0.255.41/32 - Not found",
],
},
@@ -1378,61 +1871,17 @@ DATA: list[dict[str, Any]] = [
{
"peer_address": "172.30.11.1",
"vrf": "default",
- "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"],
+ "capabilities": ["Ipv4Unicast", "ipv4 Mpls labels"],
},
{
"peer_address": "172.30.11.10",
"vrf": "MGMT",
- "capabilities": ["ipv4 Unicast", "ipv4 MplsVpn"],
+ "capabilities": ["ipv4_Unicast", "ipv4 MplsVpn"],
},
]
},
"expected": {"result": "success"},
},
- {
- "name": "failure-no-vrf",
- "test": VerifyBGPPeerMPCaps,
- "eos_data": [
- {
- "vrfs": {
- "default": {
- "peerList": [
- {
- "peerAddress": "172.30.11.1",
- "neighborCapabilities": {
- "multiprotocolCaps": {
- "ipv4Unicast": {
- "advertised": True,
- "received": True,
- "enabled": True,
- },
- "ipv4MplsVpn": {
- "advertised": True,
- "received": True,
- "enabled": True,
- },
- }
- },
- }
- ]
- }
- }
- }
- ],
- "inputs": {
- "bgp_peers": [
- {
- "peer_address": "172.30.11.1",
- "vrf": "MGMT",
- "capabilities": ["ipv4 Unicast", "ipv4mplslabels"],
- }
- ]
- },
- "expected": {
- "result": "failure",
- "messages": ["Peer: 172.30.11.1 VRF: MGMT - VRF not configured"],
- },
- },
{
"name": "failure-no-peer",
"test": VerifyBGPPeerMPCaps,
@@ -1479,12 +1928,12 @@ DATA: list[dict[str, Any]] = [
{
"peer_address": "172.30.11.10",
"vrf": "default",
- "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"],
+ "capabilities": ["ipv4Unicast", "l2-vpn-EVPN"],
},
{
"peer_address": "172.30.11.1",
"vrf": "MGMT",
- "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"],
+ "capabilities": ["ipv4Unicast", "l2vpnevpn"],
},
]
},
@@ -1613,7 +2062,7 @@ DATA: list[dict[str, Any]] = [
{
"peer_address": "172.30.11.10",
"vrf": "MGMT",
- "capabilities": ["ipv4unicast", "ipv4 mplsvpn", "L2vpnEVPN"],
+ "capabilities": ["ipv4_unicast", "ipv4 mplsvpn", "L2vpnEVPN"],
},
{
"peer_address": "172.30.11.11",
@@ -1694,13 +2143,13 @@ DATA: list[dict[str, Any]] = [
"peer_address": "172.30.11.1",
"vrf": "default",
"strict": True,
- "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"],
+ "capabilities": ["Ipv4 Unicast", "ipv4MplsLabels"],
},
{
"peer_address": "172.30.11.10",
"vrf": "MGMT",
"strict": True,
- "capabilities": ["ipv4 Unicast", "ipv4 MplsVpn"],
+ "capabilities": ["ipv4-Unicast", "ipv4MplsVpn"],
},
]
},
@@ -2323,8 +2772,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 172.30.11.1 VRF: default - Session state is not established - State: Idle",
- "Peer: 172.30.11.10 VRF: MGMT - Session state is not established - State: Idle",
+ "Peer: 172.30.11.1 VRF: default - Incorrect session state - Expected: Established Actual: Idle",
+ "Peer: 172.30.11.10 VRF: MGMT - Incorrect session state - Expected: Established Actual: Idle",
],
},
},
@@ -2981,9 +3430,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 172.30.11.1 VRF: default - Hold time mismatch - Expected: 180, Actual: 160",
- "Peer: 172.30.11.11 VRF: MGMT - Hold time mismatch - Expected: 180, Actual: 120",
- "Peer: 172.30.11.11 VRF: MGMT - Keepalive time mismatch - Expected: 60, Actual: 40",
+ "Peer: 172.30.11.1 VRF: default - Hold time mismatch - Expected: 180 Actual: 160",
+ "Peer: 172.30.11.11 VRF: MGMT - Hold time mismatch - Expected: 180 Actual: 120",
+ "Peer: 172.30.11.11 VRF: MGMT - Keepalive time mismatch - Expected: 60 Actual: 40",
],
},
},
@@ -3593,10 +4042,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.100.0.8 VRF: default - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN, Actual: RM-MLAG-PEER",
- "Peer: 10.100.0.8 VRF: default - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT, Actual: RM-MLAG-PEER",
- "Peer: 10.100.0.10 VRF: MGMT - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN, Actual: RM-MLAG-PEER",
- "Peer: 10.100.0.10 VRF: MGMT - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT, Actual: RM-MLAG-PEER",
+ "Peer: 10.100.0.8 VRF: default - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN Actual: RM-MLAG-PEER",
+ "Peer: 10.100.0.8 VRF: default - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT Actual: RM-MLAG-PEER",
+ "Peer: 10.100.0.10 VRF: MGMT - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN Actual: RM-MLAG-PEER",
+ "Peer: 10.100.0.10 VRF: MGMT - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT Actual: RM-MLAG-PEER",
],
},
},
@@ -3636,8 +4085,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.100.0.8 VRF: default - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN, Actual: RM-MLAG-PEER",
- "Peer: 10.100.0.10 VRF: MGMT - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN, Actual: RM-MLAG-PEER",
+ "Peer: 10.100.0.8 VRF: default - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN Actual: RM-MLAG-PEER",
+ "Peer: 10.100.0.10 VRF: MGMT - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN Actual: RM-MLAG-PEER",
],
},
},
@@ -3673,10 +4122,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.100.0.8 VRF: default - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN, Actual: Not Configured",
- "Peer: 10.100.0.8 VRF: default - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT, Actual: Not Configured",
- "Peer: 10.100.0.10 VRF: MGMT - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN, Actual: Not Configured",
- "Peer: 10.100.0.10 VRF: MGMT - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT, Actual: Not Configured",
+ "Peer: 10.100.0.8 VRF: default - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN Actual: Not Configured",
+ "Peer: 10.100.0.8 VRF: default - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT Actual: Not Configured",
+ "Peer: 10.100.0.10 VRF: MGMT - Inbound route-map mismatch - Expected: RM-MLAG-PEER-IN Actual: Not Configured",
+ "Peer: 10.100.0.10 VRF: MGMT - Outbound route-map mismatch - Expected: RM-MLAG-PEER-OUT Actual: Not Configured",
],
},
},
@@ -3740,6 +4189,39 @@ DATA: list[dict[str, Any]] = [
},
"expected": {"result": "success"},
},
+ {
+ "name": "success-no-warning-limit",
+ "test": VerifyBGPPeerRouteLimit,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "maxTotalRoutes": 12000,
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "maxTotalRoutes": 10000,
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default", "maximum_routes": 12000, "warning_limit": 0},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT", "maximum_routes": 10000},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
{
"name": "failure-peer-not-found",
"test": VerifyBGPPeerRouteLimit,
@@ -3801,10 +4283,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.100.0.8 VRF: default - Maximum routes mismatch - Expected: 12000, Actual: 13000",
- "Peer: 10.100.0.8 VRF: default - Maximum route warning limit mismatch - Expected: 10000, Actual: 11000",
- "Peer: 10.100.0.9 VRF: MGMT - Maximum routes mismatch - Expected: 10000, Actual: 11000",
- "Peer: 10.100.0.9 VRF: MGMT - Maximum route warning limit mismatch - Expected: 9000, Actual: 10000",
+ "Peer: 10.100.0.8 VRF: default - Maximum routes mismatch - Expected: 12000 Actual: 13000",
+ "Peer: 10.100.0.8 VRF: default - Maximum routes warning limit mismatch - Expected: 10000 Actual: 11000",
+ "Peer: 10.100.0.9 VRF: MGMT - Maximum routes mismatch - Expected: 10000 Actual: 11000",
+ "Peer: 10.100.0.9 VRF: MGMT - Maximum routes warning limit mismatch - Expected: 9000 Actual: 10000",
],
},
},
@@ -3826,6 +4308,7 @@ DATA: list[dict[str, Any]] = [
"peerList": [
{
"peerAddress": "10.100.0.9",
+ "maxTotalRoutes": 10000,
}
]
},
@@ -3841,9 +4324,2401 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.100.0.8 VRF: default - Maximum route warning limit mismatch - Expected: 10000, Actual: Not Found",
- "Peer: 10.100.0.9 VRF: MGMT - Maximum routes mismatch - Expected: 10000, Actual: Not Found",
- "Peer: 10.100.0.9 VRF: MGMT - Maximum route warning limit mismatch - Expected: 9000, Actual: Not Found",
+ "Peer: 10.100.0.8 VRF: default - Maximum routes warning limit mismatch - Expected: 10000 Actual: 0",
+ "Peer: 10.100.0.9 VRF: MGMT - Maximum routes warning limit mismatch - Expected: 9000 Actual: 0",
+ ],
+ },
+ },
+ {
+ "name": "success-no-check-tcp-queues",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "check_tcp_queues": False,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-check-tcp-queues",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "check_tcp_queues": True,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-min-established-time",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "establishedTime": 169883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "establishedTime": 169883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "check_tcp_queues": True,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-peer-not-found",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.9 VRF: MGMT - Not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-not-established",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Active",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Active",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Incorrect session state - Expected: Established Actual: Active",
+ "Peer: 10.100.0.9 VRF: MGMT - Incorrect session state - Expected: Established Actual: Active",
+ ],
+ },
+ },
+ {
+ "name": "failure-check-tcp-queues",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Session has non-empty message queues - InQ: 5 OutQ: 10",
+ ],
+ },
+ },
+ {
+ "name": "failure-min-established-time",
+ "test": VerifyBGPPeerSession,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "establishedTime": 9883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "establishedTime": 9883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "check_tcp_queues": True,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - BGP session not established for the minimum required duration - Expected: 10000s Actual: 9883s",
+ "Peer: 10.100.0.9 VRF: MGMT - BGP session not established for the minimum required duration - Expected: 10000s Actual: 9883s",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeerGroup,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "peerGroupName": "IPv4-UNDERLAY-PEERS",
+ },
+ {
+ "peerAddress": "10.100.4.5",
+ "peerGroupName": "MLAG-IPv4-UNDERLAY-PEER",
+ },
+ {
+ "peerAddress": "10.100.1.1",
+ "peerGroupName": "EVPN-OVERLAY-PEERS",
+ },
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.10",
+ "peerGroupName": "IPv4-UNDERLAY-PEERS",
+ },
+ {
+ "peerAddress": "10.100.1.2",
+ "peerGroupName": "EVPN-OVERLAY-PEERS",
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.0.10", "vrf": "MGMT", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.1.1", "vrf": "default", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.1.2", "vrf": "MGMT", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.4.5", "vrf": "default", "peer_group": "MLAG-IPv4-UNDERLAY-PEER"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-peer-group",
+ "test": VerifyBGPPeerGroup,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "peerGroupName": "UNDERLAY-PEERS",
+ },
+ {
+ "peerAddress": "10.100.1.1",
+ "peerGroupName": "OVERLAY-PEERS",
+ },
+ {
+ "peerAddress": "10.100.4.5",
+ "peerGroupName": "UNDERLAY-PEER",
+ },
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.10",
+ "peerGroupName": "UNDERLAY-PEERS",
+ },
+ {
+ "peerAddress": "10.100.1.2",
+ "peerGroupName": "OVERLAY-PEERS",
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.0.10", "vrf": "MGMT", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.1.1", "vrf": "default", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.1.2", "vrf": "MGMT", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.4.5", "vrf": "default", "peer_group": "MLAG-IPv4-UNDERLAY-PEER"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Incorrect peer group configured - Expected: IPv4-UNDERLAY-PEERS Actual: UNDERLAY-PEERS",
+ "Peer: 10.100.0.10 VRF: MGMT - Incorrect peer group configured - Expected: IPv4-UNDERLAY-PEERS Actual: UNDERLAY-PEERS",
+ "Peer: 10.100.1.1 VRF: default - Incorrect peer group configured - Expected: EVPN-OVERLAY-PEERS Actual: OVERLAY-PEERS",
+ "Peer: 10.100.1.2 VRF: MGMT - Incorrect peer group configured - Expected: EVPN-OVERLAY-PEERS Actual: OVERLAY-PEERS",
+ "Peer: 10.100.4.5 VRF: default - Incorrect peer group configured - Expected: MLAG-IPv4-UNDERLAY-PEER Actual: UNDERLAY-PEER",
+ ],
+ },
+ },
+ {
+ "name": "failure-peers-not-found",
+ "test": VerifyBGPPeerGroup,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {"peerList": []},
+ "MGMT": {"peerList": []},
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.0.10", "vrf": "MGMT", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.1.1", "vrf": "default", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.1.2", "vrf": "MGMT", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.4.5", "vrf": "default", "peer_group": "MLAG-IPv4-UNDERLAY-PEER"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Not found",
+ "Peer: 10.100.0.10 VRF: MGMT - Not found",
+ "Peer: 10.100.1.1 VRF: default - Not found",
+ "Peer: 10.100.1.2 VRF: MGMT - Not found",
+ "Peer: 10.100.4.5 VRF: default - Not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-peer-group-not-found",
+ "test": VerifyBGPPeerGroup,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ },
+ {
+ "peerAddress": "10.100.1.1",
+ },
+ {
+ "peerAddress": "10.100.4.5",
+ },
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.10",
+ },
+ {
+ "peerAddress": "10.100.1.2",
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.0.10", "vrf": "MGMT", "peer_group": "IPv4-UNDERLAY-PEERS"},
+ {"peer_address": "10.100.1.1", "vrf": "default", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.1.2", "vrf": "MGMT", "peer_group": "EVPN-OVERLAY-PEERS"},
+ {"peer_address": "10.100.4.5", "vrf": "default", "peer_group": "MLAG-IPv4-UNDERLAY-PEER"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Incorrect peer group configured - Expected: IPv4-UNDERLAY-PEERS Actual: Not Found",
+ "Peer: 10.100.0.10 VRF: MGMT - Incorrect peer group configured - Expected: IPv4-UNDERLAY-PEERS Actual: Not Found",
+ "Peer: 10.100.1.1 VRF: default - Incorrect peer group configured - Expected: EVPN-OVERLAY-PEERS Actual: Not Found",
+ "Peer: 10.100.1.2 VRF: MGMT - Incorrect peer group configured - Expected: EVPN-OVERLAY-PEERS Actual: Not Found",
+ "Peer: 10.100.4.5 VRF: default - Incorrect peer group configured - Expected: MLAG-IPv4-UNDERLAY-PEER Actual: Not Found",
+ ],
+ },
+ },
+ {
+ "name": "success-no-check-tcp-queues",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "check_tcp_queues": False,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-check-tcp-queues",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "establishedTime": 169883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "establishedTime": 169883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "check_tcp_queues": True,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-min-established-time",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "check_tcp_queues": True,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-peer-not-found",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.9 VRF: MGMT - Not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-not-established",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Active",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Active",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Incorrect session state - Expected: Established Actual: Active",
+ "Peer: 10.100.0.9 VRF: MGMT - Incorrect session state - Expected: Established Actual: Active",
+ ],
+ },
+ },
+ {
+ "name": "failure-check-tcp-queues",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Session has non-empty message queues - InQ: 5 OutQ: 10",
+ ],
+ },
+ },
+ {
+ "name": "failure-min-established-time",
+ "test": VerifyBGPPeerSessionRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "establishedTime": 9883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "establishedTime": 9883,
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "minimum_established_time": 10000,
+ "bgp_peers": [
+ {"peer_address": "10.100.0.8", "vrf": "default"},
+ {"peer_address": "10.100.0.9", "vrf": "MGMT"},
+ ],
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - BGP session not established for the minimum required duration - Expected: 10000s Actual: 9883s",
+ "Peer: 10.100.0.9 VRF: MGMT - BGP session not established for the minimum required duration - Expected: 10000s Actual: 9883s",
+ ],
+ },
+ },
+ {
+ "name": "success-no-check-tcp-queues",
+ "test": VerifyBGPPeersHealthRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "check_tcp_queues": False,
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-check-tcp-queues",
+ "test": VerifyBGPPeersHealthRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {
+ "check_tcp_queues": True,
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-established",
+ "test": VerifyBGPPeersHealthRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Active",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Active",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Incorrect session state - Expected: Established Actual: Active",
+ "Peer: 10.100.0.9 VRF: MGMT - Incorrect session state - Expected: Established Actual: Active",
+ ],
+ },
+ },
+ {
+ "name": "failure-check-tcp-queues",
+ "test": VerifyBGPPeersHealthRibd,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.8",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 10,
+ "inputQueueLength": 5,
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "10.100.0.9",
+ "state": "Established",
+ "peerTcpInfo": {
+ "outputQueueLength": 0,
+ "inputQueueLength": 0,
+ },
+ }
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Session has non-empty message queues - InQ: 5 OutQ: 10",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPNlriAcceptance,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.0.8": {
+ "peerState": "Established",
+ "peerAsn": "65100",
+ "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 17},
+ "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 56, "nlrisAccepted": 56},
+ },
+ },
+ },
+ "MGMT": {
+ "vrf": "MGMT",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.4.5": {
+ "peerState": "Established",
+ "peerAsn": "65102",
+ "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14},
+ "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 56, "nlrisAccepted": 56},
+ }
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.100.0.8",
+ "vrf": "default",
+ "capabilities": ["Ipv4 Unicast", "L2vpnEVPN"],
+ },
+ {
+ "peer_address": "10.100.4.5",
+ "vrf": "MGMT",
+ "capabilities": ["ipv4 Unicast", "L2vpnEVPN"],
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-vrf-not-configured",
+ "test": VerifyBGPNlriAcceptance,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {},
+ },
+ "MGMT": {
+ "vrf": "MGMT",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {},
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.100.0.8",
+ "vrf": "default",
+ "capabilities": ["Ipv4 Unicast", "L2vpnEVPN"],
+ },
+ {
+ "peer_address": "10.100.4.5",
+ "vrf": "MGMT",
+ "capabilities": ["ipv4 Unicast", "L2vpnEVPN"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - Not found",
+ "Peer: 10.100.4.5 VRF: MGMT - Not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-capability-not-found",
+ "test": VerifyBGPNlriAcceptance,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.0.8": {
+ "peerState": "Established",
+ "peerAsn": "65100",
+ "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 17},
+ },
+ },
+ },
+ "MGMT": {
+ "vrf": "MGMT",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.4.5": {
+ "peerState": "Established",
+ "peerAsn": "65102",
+ "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 56, "nlrisAccepted": 56},
+ }
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.100.0.8",
+ "vrf": "default",
+ "capabilities": ["Ipv4 Unicast", "L2vpnEVPN"],
+ },
+ {
+ "peer_address": "10.100.4.5",
+ "vrf": "MGMT",
+ "capabilities": ["ipv4 Unicast", "L2vpnEVPN"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - l2VpnEvpn not found",
+ "Peer: 10.100.4.5 VRF: MGMT - ipv4Unicast not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-capability-not-negotiated",
+ "test": VerifyBGPNlriAcceptance,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.0.8": {
+ "peerState": "Established",
+ "peerAsn": "65100",
+ "ipv4Unicast": {"afiSafiState": "configured", "nlrisReceived": 17, "nlrisAccepted": 17},
+ },
+ },
+ },
+ "MGMT": {
+ "vrf": "MGMT",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.4.5": {
+ "peerState": "Established",
+ "peerAsn": "65102",
+ "l2VpnEvpn": {"afiSafiState": "configured", "nlrisReceived": 56, "nlrisAccepted": 56},
+ }
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.100.0.8",
+ "vrf": "default",
+ "capabilities": ["Ipv4 Unicast", "L2vpnEVPN"],
+ },
+ {
+ "peer_address": "10.100.4.5",
+ "vrf": "MGMT",
+ "capabilities": ["ipv4 Unicast", "L2vpnEVPN"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default - ipv4Unicast not negotiated",
+ "Peer: 10.100.0.8 VRF: default - l2VpnEvpn not found",
+ "Peer: 10.100.4.5 VRF: MGMT - ipv4Unicast not found",
+ "Peer: 10.100.4.5 VRF: MGMT - l2VpnEvpn not negotiated",
+ ],
+ },
+ },
+ {
+ "name": "failure-nlris-not-accepted",
+ "test": VerifyBGPNlriAcceptance,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.0.8": {
+ "peerState": "Established",
+ "peerAsn": "65100",
+ "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 16},
+ "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 58, "nlrisAccepted": 56},
+ },
+ },
+ },
+ "MGMT": {
+ "vrf": "MGMT",
+ "routerId": "10.100.1.5",
+ "asn": "65102",
+ "peers": {
+ "10.100.4.5": {
+ "peerState": "Established",
+ "peerAsn": "65102",
+ "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 15, "nlrisAccepted": 14},
+ "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 59, "nlrisAccepted": 56},
+ }
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.100.0.8",
+ "vrf": "default",
+ "capabilities": ["Ipv4 Unicast", "L2vpnEVPN"],
+ },
+ {
+ "peer_address": "10.100.4.5",
+ "vrf": "MGMT",
+ "capabilities": ["ipv4 Unicast", "L2vpnEVPN"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.100.0.8 VRF: default AFI/SAFI: ipv4Unicast - Some NLRI were filtered or rejected - Accepted: 16 Received: 17",
+ "Peer: 10.100.0.8 VRF: default AFI/SAFI: l2VpnEvpn - Some NLRI were filtered or rejected - Accepted: 56 Received: 58",
+ "Peer: 10.100.4.5 VRF: MGMT AFI/SAFI: ipv4Unicast - Some NLRI were filtered or rejected - Accepted: 14 Received: 15",
+ "Peer: 10.100.4.5 VRF: MGMT AFI/SAFI: l2VpnEvpn - Some NLRI were filtered or rejected - Accepted: 56 Received: 59",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPRoutePaths,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "10.100.0.128/31": {
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.100.0.10",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ {
+ "nextHop": "10.100.4.5",
+ "routeType": {
+ "origin": "Incomplete",
+ },
+ },
+ ],
+ }
+ }
+ },
+ "MGMT": {
+ "bgpRouteEntries": {
+ "10.100.0.130/31": {
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.100.0.8",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ {
+ "nextHop": "10.100.0.10",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ ],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {
+ "prefix": "10.100.0.128/31",
+ "vrf": "default",
+ "paths": [{"nexthop": "10.100.0.10", "origin": "Igp"}, {"nexthop": "10.100.4.5", "origin": "Incomplete"}],
+ },
+ {
+ "prefix": "10.100.0.130/31",
+ "vrf": "MGMT",
+ "paths": [{"nexthop": "10.100.0.8", "origin": "Igp"}, {"nexthop": "10.100.0.10", "origin": "Igp"}],
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-origin-not-correct",
+ "test": VerifyBGPRoutePaths,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "10.100.0.128/31": {
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.100.0.10",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ {
+ "nextHop": "10.100.4.5",
+ "routeType": {
+ "origin": "Incomplete",
+ },
+ },
+ ],
+ }
+ }
+ },
+ "MGMT": {
+ "bgpRouteEntries": {
+ "10.100.0.130/31": {
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.100.0.8",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ {
+ "nextHop": "10.100.0.10",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ ],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {
+ "prefix": "10.100.0.128/31",
+ "vrf": "default",
+ "paths": [{"nexthop": "10.100.0.10", "origin": "Incomplete"}, {"nexthop": "10.100.4.5", "origin": "Igp"}],
+ },
+ {
+ "prefix": "10.100.0.130/31",
+ "vrf": "MGMT",
+ "paths": [{"nexthop": "10.100.0.8", "origin": "Incomplete"}, {"nexthop": "10.100.0.10", "origin": "Incomplete"}],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Prefix: 10.100.0.128/31 VRF: default Next-hop: 10.100.0.10 Origin: Incomplete - Origin mismatch - Actual: Igp",
+ "Prefix: 10.100.0.128/31 VRF: default Next-hop: 10.100.4.5 Origin: Igp - Origin mismatch - Actual: Incomplete",
+ "Prefix: 10.100.0.130/31 VRF: MGMT Next-hop: 10.100.0.8 Origin: Incomplete - Origin mismatch - Actual: Igp",
+ "Prefix: 10.100.0.130/31 VRF: MGMT Next-hop: 10.100.0.10 Origin: Incomplete - Origin mismatch - Actual: Igp",
+ ],
+ },
+ },
+ {
+ "name": "failure-path-not-found",
+ "test": VerifyBGPRoutePaths,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "10.100.0.128/31": {
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.100.0.15",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ ],
+ }
+ }
+ },
+ "MGMT": {
+ "bgpRouteEntries": {
+ "10.100.0.130/31": {
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.100.0.15",
+ "routeType": {
+ "origin": "Igp",
+ },
+ },
+ ],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {
+ "prefix": "10.100.0.128/31",
+ "vrf": "default",
+ "paths": [{"nexthop": "10.100.0.10", "origin": "Incomplete"}, {"nexthop": "10.100.4.5", "origin": "Igp"}],
+ },
+ {
+ "prefix": "10.100.0.130/31",
+ "vrf": "MGMT",
+ "paths": [{"nexthop": "10.100.0.8", "origin": "Incomplete"}, {"nexthop": "10.100.0.10", "origin": "Incomplete"}],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Prefix: 10.100.0.128/31 VRF: default Next-hop: 10.100.0.10 Origin: Incomplete - Path not found",
+ "Prefix: 10.100.0.128/31 VRF: default Next-hop: 10.100.4.5 Origin: Igp - Path not found",
+ "Prefix: 10.100.0.130/31 VRF: MGMT Next-hop: 10.100.0.8 Origin: Incomplete - Path not found",
+ "Prefix: 10.100.0.130/31 VRF: MGMT Next-hop: 10.100.0.10 Origin: Incomplete - Path not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-prefix-not-found",
+ "test": VerifyBGPRoutePaths,
+ "eos_data": [
+ {"vrfs": {"default": {"bgpRouteEntries": {}}, "MGMT": {"bgpRouteEntries": {}}}},
+ ],
+ "inputs": {
+ "route_entries": [
+ {
+ "prefix": "10.100.0.128/31",
+ "vrf": "default",
+ "paths": [{"nexthop": "10.100.0.10", "origin": "Incomplete"}, {"nexthop": "10.100.4.5", "origin": "Igp"}],
+ },
+ {
+ "prefix": "10.100.0.130/31",
+ "vrf": "MGMT",
+ "paths": [{"nexthop": "10.100.0.8", "origin": "Incomplete"}, {"nexthop": "10.100.0.10", "origin": "Incomplete"}],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["Prefix: 10.100.0.128/31 VRF: default - Prefix not found", "Prefix: 10.100.0.130/31 VRF: MGMT - Prefix not found"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPRouteECMP,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.111.254.1",
+ "asn": "65101",
+ "bgpRouteEntries": {
+ "10.111.134.0/24": {
+ "address": "10.111.134.0",
+ "maskLength": 24,
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.111.2.0",
+ "routeType": {
+ "valid": True,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.111.1.0",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ ],
+ "totalPaths": 2,
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]},
+ "10.111.134.0/24": {
+ "routeType": "eBGP",
+ "vias": [
+ {"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"},
+ {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"},
+ ],
+ "directlyConnected": False,
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-prefix-not-found-bgp-table",
+ "test": VerifyBGPRouteECMP,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.111.254.1",
+ "asn": "65101",
+ "bgpRouteEntries": {
+ "10.111.134.0/24": {
+ "address": "10.111.134.0",
+ "maskLength": 24,
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.111.1.0",
+ "routeType": {
+ "valid": True,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.111.2.0",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.255.255.2",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": False,
+ "ecmpContributor": False,
+ },
+ },
+ ],
+ "totalPaths": 3,
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]},
+ "10.111.134.0/24": {
+ "routeType": "eBGP",
+ "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}],
+ "directlyConnected": False,
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"route_entries": [{"prefix": "10.111.124.0/24", "vrf": "default", "ecmp_count": 2}]},
+ "expected": {"result": "failure", "messages": ["Prefix: 10.111.124.0/24 VRF: default - Prefix not found in BGP table"]},
+ },
+ {
+ "name": "failure-valid-active-ecmp-head-not-found",
+ "test": VerifyBGPRouteECMP,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.111.254.1",
+ "asn": "65101",
+ "bgpRouteEntries": {
+ "10.111.134.0/24": {
+ "address": "10.111.134.0",
+ "maskLength": 24,
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.111.1.0",
+ "routeType": {
+ "valid": False,
+ "active": True,
+ "ecmpHead": False,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.111.2.0",
+ "routeType": {
+ "valid": False,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.255.255.2",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": False,
+ "ecmpContributor": False,
+ },
+ },
+ ],
+ "totalPaths": 3,
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]},
+ "10.111.134.0/24": {
+ "routeType": "eBGP",
+ "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}],
+ "directlyConnected": False,
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]},
+ "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - Valid and active ECMP head not found"]},
+ },
+ {
+ "name": "failure-ecmp-count-mismatch",
+ "test": VerifyBGPRouteECMP,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.111.254.1",
+ "asn": "65101",
+ "bgpRouteEntries": {
+ "10.111.134.0/24": {
+ "address": "10.111.134.0",
+ "maskLength": 24,
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.111.1.0",
+ "routeType": {
+ "valid": True,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.111.2.0",
+ "routeType": {
+ "valid": False,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.255.255.2",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": False,
+ "ecmpContributor": False,
+ },
+ },
+ ],
+ "totalPaths": 3,
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]},
+ "10.111.134.0/24": {
+ "routeType": "eBGP",
+ "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}],
+ "directlyConnected": False,
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]},
+ "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - ECMP count mismatch - Expected: 2 Actual: 1"]},
+ },
+ {
+ "name": "failure-prefix-not-found-routing-table",
+ "test": VerifyBGPRouteECMP,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.111.254.1",
+ "asn": "65101",
+ "bgpRouteEntries": {
+ "10.111.134.0/24": {
+ "address": "10.111.134.0",
+ "maskLength": 24,
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.111.1.0",
+ "routeType": {
+ "valid": True,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.111.2.0",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.255.255.2",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": False,
+ "ecmpContributor": False,
+ },
+ },
+ ],
+ "totalPaths": 3,
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]},
+ "10.111.114.0/24": {
+ "routeType": "eBGP",
+ "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}],
+ "directlyConnected": False,
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]},
+ "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - Prefix not found in routing table"]},
+ },
+ {
+ "name": "failure-nexthops-mismatch",
+ "test": VerifyBGPRouteECMP,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.111.254.1",
+ "asn": "65101",
+ "bgpRouteEntries": {
+ "10.111.134.0/24": {
+ "address": "10.111.134.0",
+ "maskLength": 24,
+ "bgpRoutePaths": [
+ {
+ "nextHop": "10.111.1.0",
+ "routeType": {
+ "valid": True,
+ "active": True,
+ "ecmpHead": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.111.2.0",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": True,
+ "ecmpContributor": True,
+ },
+ },
+ {
+ "nextHop": "10.255.255.2",
+ "routeType": {
+ "valid": True,
+ "active": False,
+ "ecmpHead": False,
+ "ecmp": False,
+ "ecmpContributor": False,
+ },
+ },
+ ],
+ "totalPaths": 3,
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]},
+ "10.111.134.0/24": {
+ "routeType": "eBGP",
+ "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}],
+ "directlyConnected": False,
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]},
+ "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - Nexthops count mismatch - BGP: 2 RIB: 1"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPRedistribution,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "afiSafiConfig": {
+ "v4u": {
+ "redistributedRoutes": [
+ {"proto": "Connected", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"},
+ {"proto": "Static", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"},
+ ]
+ },
+ "v6m": {
+ "redistributedRoutes": [
+ {"proto": "OSPFv3 External", "routeMap": "RM-CONN-2-BGP"},
+ {"proto": "IS-IS", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"},
+ ]
+ },
+ }
+ },
+ "test": {
+ "afiSafiConfig": {
+ "v4m": {
+ "redistributedRoutes": [
+ {"proto": "AttachedHost", "routeMap": "RM-CONN-2-BGP"},
+ {"proto": "OSPF Internal", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"},
+ ]
+ },
+ "v6u": {
+ "redistributedRoutes": [
+ {"proto": "DHCP", "routeMap": "RM-CONN-2-BGP"},
+ {"proto": "Bgp", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"},
+ ]
+ },
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "vrfs": [
+ {
+ "vrf": "default",
+ "address_families": [
+ {
+ "afi_safi": "ipv4Unicast",
+ "redistributed_routes": [
+ {"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ {"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ {
+ "afi_safi": "IPv6 multicast",
+ "redistributed_routes": [
+ {"proto": "OSPFv3 External", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "IS-IS", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ {
+ "vrf": "test",
+ "address_families": [
+ {
+ "afi_safi": "ipv4 Multicast",
+ "redistributed_routes": [
+ {"proto": "AttachedHost", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "OSPF Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ {
+ "afi_safi": "IPv6Unicast",
+ "redistributed_routes": [
+ {"proto": "DHCP", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-vrf-not-found",
+ "test": VerifyBGPRedistribution,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {"afiSafiConfig": {"v6m": {"redistributedRoutes": [{"proto": "Connected", "routeMap": "RM-CONN-2-BGP"}]}}},
+ "tenant": {"afiSafiConfig": {"v4u": {"redistributedRoutes": [{"proto": "Connected"}]}}},
+ }
+ }
+ ],
+ "inputs": {
+ "vrfs": [
+ {
+ "vrf": "default",
+ "address_families": [
+ {
+ "afi_safi": "ipv6 Multicast",
+ "redistributed_routes": [{"proto": "Connected", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}],
+ },
+ ],
+ },
+ {
+ "vrf": "test",
+ "address_families": [
+ {
+ "afi_safi": "ipv6 Multicast",
+ "redistributed_routes": [
+ {"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["VRF: test - Not configured"]},
+ },
+ {
+ "name": "failure-afi-safi-config-not-found",
+ "test": VerifyBGPRedistribution,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {"afiSafiConfig": {"v6m": {}}},
+ "test": {"afiSafiConfig": {"v4u": {"redistributedRoutes": [{"proto": "Connected", "routeMap": "RM-CONN-2-BGP"}]}}},
+ }
+ }
+ ],
+ "inputs": {
+ "vrfs": [
+ {
+ "vrf": "default",
+ "address_families": [
+ {
+ "afi_safi": "ipv6 Multicast",
+ "redistributed_routes": [
+ {"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ {"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["VRF: default, AFI-SAFI: IPv6 Multicast - Not redistributed"]},
+ },
+ {
+ "name": "failure-expected-proto-not-found",
+ "test": VerifyBGPRedistribution,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "afiSafiConfig": {
+ "v4m": {
+ "redistributedRoutes": [{"proto": "AttachedHost", "routeMap": "RM-CONN-2-BGP"}, {"proto": "IS-IS", "routeMap": "RM-MLAG-PEER-IN"}]
+ }
+ }
+ },
+ "test": {
+ "afiSafiConfig": {
+ "v6u": {
+ "redistributedRoutes": [{"proto": "Static", "routeMap": "RM-CONN-2-BGP"}],
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "vrfs": [
+ {
+ "vrf": "default",
+ "address_families": [
+ {
+ "afi_safi": "ipv4 multicast",
+ "redistributed_routes": [
+ {"proto": "OSPFv3 External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ {"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ }
+ ],
+ },
+ {
+ "vrf": "test",
+ "address_families": [
+ {
+ "afi_safi": "IPv6Unicast",
+ "redistributed_routes": [
+ {"proto": "DHCP", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "VRF: default, AFI-SAFI: IPv4 Multicast, Proto: OSPFv3 External - Not configured",
+ "VRF: default, AFI-SAFI: IPv4 Multicast, Proto: OSPFv3 Nssa-External - Not configured",
+ "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: DHCP - Not configured",
+ "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: Bgp - Not configured",
+ ],
+ },
+ },
+ {
+ "name": "failure-route-map-not-found",
+ "test": VerifyBGPRedistribution,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {"afiSafiConfig": {"v4u": {"redistributedRoutes": [{"proto": "Connected", "routeMap": "RM-CONN-10-BGP"}, {"proto": "Static"}]}}},
+ "test": {
+ "afiSafiConfig": {
+ "v6u": {
+ "redistributedRoutes": [{"proto": "EOS SDK", "routeMap": "RM-MLAG-PEER-IN"}, {"proto": "DHCP"}],
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "vrfs": [
+ {
+ "vrf": "default",
+ "address_families": [
+ {
+ "afi_safi": "ipv4 UNicast",
+ "redistributed_routes": [
+ {"proto": "Connected", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "Static", "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ {
+ "vrf": "test",
+ "address_families": [
+ {
+ "afi_safi": "ipv6-Unicast",
+ "redistributed_routes": [
+ {"proto": "User", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "DHCP", "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "VRF: default, AFI-SAFI: IPv4 Unicast, Proto: Connected, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: RM-CONN-10-BGP",
+ "VRF: default, AFI-SAFI: IPv4 Unicast, Proto: Static, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: Not Found",
+ "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: EOS SDK, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: RM-MLAG-PEER-IN",
+ "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: DHCP, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: Not Found",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-value-include-leaked",
+ "test": VerifyBGPRedistribution,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "afiSafiConfig": {
+ "v4m": {
+ "redistributedRoutes": [
+ {"proto": "Connected", "routeMap": "RM-CONN-2-BGP"},
+ {"proto": "IS-IS", "includeLeaked": False, "routeMap": "RM-CONN-2-BGP"},
+ ]
+ },
+ }
+ },
+ "test": {
+ "afiSafiConfig": {
+ "v6u": {
+ "redistributedRoutes": [
+ {"proto": "Dynamic", "routeMap": "RM-CONN-2-BGP"},
+ {"proto": "Bgp", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"},
+ ]
+ },
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "vrfs": [
+ {
+ "vrf": "default",
+ "address_families": [
+ {
+ "afi_safi": "ipv4-multicast",
+ "redistributed_routes": [
+ {"proto": "IS-IS", "include_leaked": True, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ {
+ "vrf": "test",
+ "address_families": [
+ {
+ "afi_safi": "IPv6_unicast",
+ "redistributed_routes": [
+ {"proto": "Dynamic", "route_map": "RM-CONN-2-BGP"},
+ {"proto": "Bgp", "include_leaked": False, "route_map": "RM-CONN-2-BGP"},
+ ],
+ },
+ ],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "VRF: default, AFI-SAFI: IPv4 Multicast, Proto: IS-IS, Include Leaked: True, Route Map: RM-CONN-2-BGP - Include leaked mismatch - Actual: False",
+ "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: Bgp, Route Map: RM-CONN-2-BGP - Include leaked mismatch - Actual: True",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeerTtlMultiHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.111.0.1",
+ "ttl": 2,
+ "maxTtlHops": 2,
+ },
+ {
+ "peerAddress": "10.111.0.2",
+ "ttl": 1,
+ "maxTtlHops": 1,
+ },
+ ]
+ },
+ "Test": {"peerList": [{"peerAddress": "10.111.0.3", "ttl": 255, "maxTtlHops": 255}]},
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.111.0.1",
+ "vrf": "default",
+ "ttl": 2,
+ "max_ttl_hops": 2,
+ },
+ {
+ "peer_address": "10.111.0.2",
+ "vrf": "default",
+ "ttl": 1,
+ "max_ttl_hops": 1,
+ },
+ {
+ "peer_address": "10.111.0.3",
+ "vrf": "Test",
+ "ttl": 255,
+ "max_ttl_hops": 255,
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-peer-not-found",
+ "test": VerifyBGPPeerTtlMultiHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.111.0.4",
+ "ttl": 2,
+ "maxTtlHops": 2,
+ },
+ {
+ "peerAddress": "10.111.0.5",
+ "ttl": 1,
+ "maxTtlHops": 1,
+ },
+ ]
+ },
+ "Test": {"peerList": [{"peerAddress": "10.111.0.6", "ttl": 255, "maxTtlHops": 255}]},
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.111.0.1",
+ "vrf": "default",
+ "ttl": 2,
+ "max_ttl_hops": 2,
+ },
+ {
+ "peer_address": "10.111.0.2",
+ "vrf": "Test",
+ "ttl": 255,
+ "max_ttl_hops": 255,
+ },
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["Peer: 10.111.0.1 VRF: default - Not found", "Peer: 10.111.0.2 VRF: Test - Not found"]},
+ },
+ {
+ "name": "failure-ttl-time-mismatch",
+ "test": VerifyBGPPeerTtlMultiHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.111.0.1",
+ "ttl": 12,
+ "maxTtlHops": 2,
+ },
+ {
+ "peerAddress": "10.111.0.2",
+ "ttl": 120,
+ "maxTtlHops": 1,
+ },
+ ]
+ },
+ "Test": {"peerList": [{"peerAddress": "10.111.0.3", "ttl": 205, "maxTtlHops": 255}]},
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.111.0.1",
+ "vrf": "default",
+ "ttl": 2,
+ "max_ttl_hops": 2,
+ },
+ {
+ "peer_address": "10.111.0.2",
+ "vrf": "default",
+ "ttl": 1,
+ "max_ttl_hops": 1,
+ },
+ {
+ "peer_address": "10.111.0.3",
+ "vrf": "Test",
+ "ttl": 255,
+ "max_ttl_hops": 255,
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.111.0.1 VRF: default - TTL mismatch - Expected: 2 Actual: 12",
+ "Peer: 10.111.0.2 VRF: default - TTL mismatch - Expected: 1 Actual: 120",
+ "Peer: 10.111.0.3 VRF: Test - TTL mismatch - Expected: 255 Actual: 205",
+ ],
+ },
+ },
+ {
+ "name": "failure-max-ttl-hops-mismatch",
+ "test": VerifyBGPPeerTtlMultiHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "10.111.0.1",
+ "ttl": 2,
+ "maxTtlHops": 12,
+ },
+ {
+ "peerAddress": "10.111.0.2",
+ "ttl": 1,
+ "maxTtlHops": 100,
+ },
+ ]
+ },
+ "Test": {"peerList": [{"peerAddress": "10.111.0.3", "ttl": 255, "maxTtlHops": 205}]},
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "10.111.0.1",
+ "vrf": "default",
+ "ttl": 2,
+ "max_ttl_hops": 2,
+ },
+ {
+ "peer_address": "10.111.0.2",
+ "vrf": "default",
+ "ttl": 1,
+ "max_ttl_hops": 1,
+ },
+ {
+ "peer_address": "10.111.0.3",
+ "vrf": "Test",
+ "ttl": 255,
+ "max_ttl_hops": 255,
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 10.111.0.1 VRF: default - Max TTL Hops mismatch - Expected: 2 Actual: 12",
+ "Peer: 10.111.0.2 VRF: default - Max TTL Hops mismatch - Expected: 1 Actual: 100",
+ "Peer: 10.111.0.3 VRF: Test - Max TTL Hops mismatch - Expected: 255 Actual: 205",
],
},
},
diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py
index 4e9d654..2e67f91 100644
--- a/tests/units/anta_tests/routing/test_generic.py
+++ b/tests/units/anta_tests/routing/test_generic.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.generic.py."""
@@ -11,7 +11,7 @@ from typing import Any
import pytest
from pydantic import ValidationError
-from anta.tests.routing.generic import VerifyIPv4RouteType, VerifyRoutingProtocolModel, VerifyRoutingTableEntry, VerifyRoutingTableSize
+from anta.tests.routing.generic import VerifyIPv4RouteNextHops, VerifyIPv4RouteType, VerifyRoutingProtocolModel, VerifyRoutingTableEntry, VerifyRoutingTableSize
from tests.units.anta_tests import test
DATA: list[dict[str, Any]] = [
@@ -27,14 +27,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifyRoutingProtocolModel,
"eos_data": [{"vrfs": {"default": {}}, "protoModelStatus": {"configuredProtoModel": "ribd", "operatingProtoModel": "ribd"}}],
"inputs": {"model": "multi-agent"},
- "expected": {"result": "failure", "messages": ["routing model is misconfigured: configured: ribd - operating: ribd - expected: multi-agent"]},
+ "expected": {"result": "failure", "messages": ["Routing model is misconfigured - Expected: multi-agent Actual: ribd"]},
},
{
"name": "failure-mismatch-operating-model",
"test": VerifyRoutingProtocolModel,
"eos_data": [{"vrfs": {"default": {}}, "protoModelStatus": {"configuredProtoModel": "multi-agent", "operatingProtoModel": "ribd"}}],
"inputs": {"model": "multi-agent"},
- "expected": {"result": "failure", "messages": ["routing model is misconfigured: configured: multi-agent - operating: ribd - expected: multi-agent"]},
+ "expected": {"result": "failure", "messages": ["Routing model is misconfigured - Expected: multi-agent Actual: ribd"]},
},
{
"name": "success",
@@ -68,7 +68,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"minimum": 42, "maximum": 666},
- "expected": {"result": "failure", "messages": ["routing-table has 1000 routes and not between min (42) and maximum (666)"]},
+ "expected": {"result": "failure", "messages": ["Routing table routes are outside the routes range - Expected: 42 <= to >= 666 Actual: 1000"]},
},
{
"name": "success",
@@ -204,9 +204,20 @@ DATA: list[dict[str, Any]] = [
},
},
},
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {},
+ },
+ },
+ },
],
- "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]},
- "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.1']"]},
+ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2", "10.1.0.3"]},
+ "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: 10.1.0.1, 10.1.0.3"]},
},
{
"name": "failure-wrong-route",
@@ -260,7 +271,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]},
- "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]},
+ "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: 10.1.0.2"]},
},
{
"name": "failure-wrong-route-collect-all",
@@ -302,7 +313,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "all"},
- "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]},
+ "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: 10.1.0.2"]},
},
{
"name": "success-valid-route-type",
@@ -348,6 +359,164 @@ DATA: list[dict[str, Any]] = [
"inputs": {"routes_entries": [{"vrf": "default", "prefix": "10.10.0.1/32", "route_type": "eBGP"}]},
"expected": {"result": "failure", "messages": ["Prefix: 10.10.0.1/32 VRF: default - VRF not configured"]},
},
+ {
+ "name": "success",
+ "test": VerifyIPv4RouteNextHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.10.0.1/32": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ "MGMT": {
+ "routes": {
+ "10.100.0.128/31": {
+ "vias": [
+ {"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"},
+ {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"},
+ {"nexthopAddr": "10.100.0.101", "interface": "Ethernet4"},
+ ],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {"prefix": "10.10.0.1/32", "vrf": "default", "nexthops": ["10.100.0.10", "10.100.0.8"]},
+ {"prefix": "10.100.0.128/31", "vrf": "MGMT", "nexthops": ["10.100.0.8", "10.100.0.10"]},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-strict-true",
+ "test": VerifyIPv4RouteNextHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.10.0.1/32": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ "MGMT": {
+ "routes": {
+ "10.100.0.128/31": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {"prefix": "10.10.0.1/32", "vrf": "default", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10"]},
+ {"prefix": "10.100.0.128/31", "vrf": "MGMT", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10"]},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyIPv4RouteNextHops,
+ "eos_data": [
+ {"vrfs": {"default": {"routes": {}}, "MGMT": {"routes": {}}}},
+ ],
+ "inputs": {
+ "route_entries": [
+ {"prefix": "10.10.0.1/32", "vrf": "default", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10"]},
+ {"prefix": "10.100.0.128/31", "vrf": "MGMT", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10"]},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["Prefix: 10.10.0.1/32 VRF: default - prefix not found", "Prefix: 10.100.0.128/31 VRF: MGMT - prefix not found"],
+ },
+ },
+ {
+ "name": "failure-strict-failed",
+ "test": VerifyIPv4RouteNextHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.10.0.1/32": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ "MGMT": {
+ "routes": {
+ "10.100.0.128/31": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.11", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {"prefix": "10.10.0.1/32", "vrf": "default", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10", "10.100.0.11"]},
+ {"prefix": "10.100.0.128/31", "vrf": "MGMT", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10"]},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Prefix: 10.10.0.1/32 VRF: default - List of next-hops not matching - Expected: 10.100.0.10, 10.100.0.11, 10.100.0.8 "
+ "Actual: 10.100.0.10, 10.100.0.8",
+ "Prefix: 10.100.0.128/31 VRF: MGMT - List of next-hops not matching - Expected: 10.100.0.10, 10.100.0.8 Actual: 10.100.0.11, 10.100.0.8",
+ ],
+ },
+ },
+ {
+ "name": "failure",
+ "test": VerifyIPv4RouteNextHops,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routes": {
+ "10.10.0.1/32": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ "MGMT": {
+ "routes": {
+ "10.100.0.128/31": {
+ "vias": [{"nexthopAddr": "10.100.0.8", "interface": "Ethernet1"}, {"nexthopAddr": "10.100.0.10", "interface": "Ethernet2"}],
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "route_entries": [
+ {"prefix": "10.10.0.1/32", "vrf": "default", "nexthops": ["10.100.0.8", "10.100.0.10", "10.100.0.11"]},
+ {"prefix": "10.100.0.128/31", "vrf": "MGMT", "nexthops": ["10.100.0.8", "10.100.0.10", "10.100.0.11"]},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Prefix: 10.10.0.1/32 VRF: default Nexthop: 10.100.0.11 - Route not found",
+ "Prefix: 10.100.0.128/31 VRF: MGMT Nexthop: 10.100.0.11 - Route not found",
+ ],
+ },
+ },
]
diff --git a/tests/units/anta_tests/routing/test_isis.py b/tests/units/anta_tests/routing/test_isis.py
index 84f5bdc..8a36d82 100644
--- a/tests/units/anta_tests/routing/test_isis.py
+++ b/tests/units/anta_tests/routing/test_isis.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -18,13 +18,12 @@ from anta.tests.routing.isis import (
VerifyISISSegmentRoutingAdjacencySegments,
VerifyISISSegmentRoutingDataplane,
VerifyISISSegmentRoutingTunnels,
- _get_interface_data,
)
from tests.units.anta_tests import test
DATA: list[dict[str, Any]] = [
{
- "name": "success only default vrf",
+ "name": "success-default-vrf",
"test": VerifyISISNeighborState,
"eos_data": [
{
@@ -60,7 +59,27 @@ DATA: list[dict[str, Any]] = [
}
}
}
- }
+ },
+ "customer": {
+ "isisInstances": {
+ "CORE-ISIS": {
+ "neighbors": {
+ "0168.0000.0112": {
+ "adjacencies": [
+ {
+ "hostname": "s1-p02",
+ "circuitId": "87",
+ "interfaceName": "Ethernet2",
+ "state": "down",
+ "lastHelloTime": 1713688405,
+ "routerIdV4": "1.0.0.112",
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
}
},
],
@@ -68,7 +87,7 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "success different vrfs",
+ "name": "success-multiple-vrfs",
"test": VerifyISISNeighborState,
"eos_data": [
{
@@ -92,31 +111,31 @@ DATA: list[dict[str, Any]] = [
},
},
},
- "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",
- }
- ]
- }
+ },
+ "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,
+ "inputs": {"check_all_vrfs": True},
"expected": {"result": "success"},
},
{
@@ -163,23 +182,101 @@ DATA: list[dict[str, Any]] = [
"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'}]."],
+ "messages": ["Instance: CORE-ISIS VRF: default Interface: Ethernet1 - Incorrect adjacency state - Expected: up Actual: down"],
},
},
{
- "name": "skipped - no neighbor",
+ "name": "skipped-not-configured",
"test": VerifyISISNeighborState,
"eos_data": [
- {"vrfs": {"default": {"isisInstances": {"CORE-ISIS": {"neighbors": {}}}}}},
+ {"vrfs": {}},
],
"inputs": None,
+ "expected": {
+ "result": "skipped",
+ "messages": ["IS-IS not configured"],
+ },
+ },
+ {
+ "name": "failure-multiple-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": "down",
+ "lastHelloTime": 1713688405,
+ "routerIdV4": "1.0.0.112",
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {"check_all_vrfs": True},
+ "expected": {
+ "result": "failure",
+ "messages": ["Instance: CORE-ISIS VRF: customer Interface: Ethernet2 - Incorrect adjacency state - Expected: up Actual: down"],
+ },
+ },
+ {
+ "name": "skipped-no-neighbor-detected",
+ "test": VerifyISISNeighborState,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "isisInstances": {
+ "CORE-ISIS": {
+ "neighbors": {},
+ },
+ },
+ },
+ "customer": {"isisInstances": {"CORE-ISIS": {"neighbors": {}}}},
+ }
+ },
+ ],
+ "inputs": {"check_all_vrfs": True},
"expected": {
"result": "skipped",
"messages": ["No IS-IS neighbor detected"],
},
},
{
- "name": "success only default vrf",
+ "name": "success-default-vrf",
"test": VerifyISISNeighborCount,
"eos_data": [
{
@@ -251,10 +348,108 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "skipped - no neighbor",
+ "name": "success-multiple-VRFs",
"test": VerifyISISNeighborCount,
"eos_data": [
- {"vrfs": {"default": {"isisInstances": {"CORE-ISIS": {"interfaces": {}}}}}},
+ {
+ "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,
+ },
+ }
+ }
+ }
+ },
+ "PROD": {
+ "isisInstances": {
+ "PROD-ISIS": {
+ "interfaces": {
+ "Ethernet3": {
+ "enabled": True,
+ "intfLevels": {
+ "1": {
+ "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},
+ {"name": "Ethernet3", "vrf": "PROD", "level": 1, "count": 1},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped-not-configured",
+ "test": VerifyISISNeighborCount,
+ "eos_data": [
+ {"vrfs": {}},
],
"inputs": {
"interfaces": [
@@ -263,11 +458,11 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "skipped",
- "messages": ["No IS-IS neighbor detected"],
+ "messages": ["IS-IS not configured"],
},
},
{
- "name": "failure - missing interface",
+ "name": "failure-interface-not-configured",
"test": VerifyISISNeighborCount,
"eos_data": [
{
@@ -306,11 +501,80 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["No neighbor detected for interface Ethernet2"],
+ "messages": ["Interface: Ethernet2 VRF: default Level: 2 - Not configured"],
},
},
{
- "name": "failure - wrong count",
+ "name": "success-interface-is-in-wrong-vrf",
+ "test": VerifyISISNeighborCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "isisInstances": {
+ "CORE-ISIS": {
+ "interfaces": {
+ "Ethernet1": {
+ "intfLevels": {
+ "2": {
+ "ipv4Metric": 10,
+ "numAdjacencies": 1,
+ "linkId": "84",
+ "sharedSecretProfile": "",
+ "isisAdjacencies": [],
+ "passive": False,
+ "v4Protection": "link",
+ "v6Protection": "disabled",
+ }
+ },
+ "interfaceSpeed": 1000,
+ "areaProxyBoundary": False,
+ },
+ }
+ }
+ }
+ },
+ "PROD": {
+ "isisInstances": {
+ "PROD-ISIS": {
+ "interfaces": {
+ "Ethernet2": {
+ "enabled": True,
+ "intfLevels": {
+ "1": {
+ "ipv4Metric": 10,
+ "numAdjacencies": 1,
+ "linkId": "88",
+ "sharedSecretProfile": "",
+ "isisAdjacencies": [],
+ "passive": False,
+ "v4Protection": "link",
+ "v6Protection": "disabled",
+ }
+ },
+ "interfaceSpeed": 1000,
+ "areaProxyBoundary": False,
+ },
+ }
+ }
+ }
+ },
+ }
+ },
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "level": 2, "count": 1},
+ {"name": "Ethernet1", "vrf": "PROD", "level": 1, "count": 1},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["Interface: Ethernet2 VRF: default Level: 2 - Not configured", "Interface: Ethernet1 VRF: PROD Level: 1 - Not configured"],
+ },
+ },
+ {
+ "name": "failure-wrong-count",
"test": VerifyISISNeighborCount,
"eos_data": [
{
@@ -349,11 +613,11 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Interface Ethernet1: expected Level 2: count 1, got Level 2: count 3"],
+ "messages": ["Interface: Ethernet1 VRF: default Level: 2 - Neighbor count mismatch - Expected: 1 Actual: 3"],
},
},
{
- "name": "success VerifyISISInterfaceMode only default vrf",
+ "name": "success-default-vrf",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
@@ -435,7 +699,118 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "failure VerifyISISInterfaceMode default vrf with interface not running passive mode",
+ "name": "success-multiple-VRFs",
+ "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,
+ },
+ }
+ }
+ }
+ },
+ "PROD": {
+ "isisInstances": {
+ "PROD-ISIS": {
+ "interfaces": {
+ "Ethernet4": {
+ "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,
+ },
+ "Ethernet5": {
+ "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": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
+ {"name": "Ethernet4", "mode": "point-to-point", "vrf": "PROD"},
+ {"name": "Ethernet5", "mode": "passive", "vrf": "PROD"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-interface-not-passive",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
@@ -516,11 +891,11 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Interface Ethernet2 in VRF default is not running in passive mode"],
+ "messages": ["Interface: Ethernet2 VRF: default Level: 2 - Not running in passive mode"],
},
},
{
- "name": "failure VerifyISISInterfaceMode default vrf with interface not running point-point mode",
+ "name": "failure-interface-not-point-to-point",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
@@ -601,11 +976,11 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Interface Ethernet1 in VRF default is not running in point-to-point reporting broadcast"],
+ "messages": ["Interface: Ethernet1 VRF: default Level: 2 - Incorrect interface mode - Expected: point-to-point Actual: broadcast"],
},
},
{
- "name": "failure VerifyISISInterfaceMode default vrf with interface not running correct VRF mode",
+ "name": "failure-interface-wrong-vrf",
"test": VerifyISISInterfaceMode,
"eos_data": [
{
@@ -687,14 +1062,14 @@ DATA: list[dict[str, Any]] = [
"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",
+ "Interface: Loopback0 VRF: default Level: 2 - Not configured",
+ "Interface: Ethernet2 VRF: default Level: 2 - Not configured",
+ "Interface: Ethernet1 VRF: default Level: 2 - Not configured",
],
},
},
{
- "name": "skipped VerifyISISInterfaceMode no vrf",
+ "name": "skipped-not-configured",
"test": VerifyISISInterfaceMode,
"eos_data": [{"vrfs": {}}],
"inputs": {
@@ -704,10 +1079,128 @@ DATA: list[dict[str, Any]] = [
{"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
]
},
- "expected": {"result": "skipped", "messages": ["IS-IS is not configured on device"]},
+ "expected": {"result": "skipped", "messages": ["IS-IS not configured"]},
},
{
- "name": "Skipped of VerifyISISSegmentRoutingAdjacencySegments no VRF.",
+ "name": "failure-multiple-VRFs",
+ "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,
+ },
+ }
+ }
+ }
+ },
+ "PROD": {
+ "isisInstances": {
+ "PROD-ISIS": {
+ "interfaces": {
+ "Ethernet4": {
+ "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,
+ },
+ "Ethernet5": {
+ "enabled": True,
+ "interfaceType": "broadcast",
+ "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": "Ethernet1", "mode": "point-to-point", "vrf": "default"},
+ {"name": "Ethernet4", "mode": "point-to-point", "vrf": "PROD"},
+ {"name": "Ethernet5", "mode": "passive", "vrf": "PROD"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Interface: Ethernet1 VRF: default Level: 2 - Incorrect interface mode - Expected: point-to-point Actual: broadcast",
+ "Interface: Ethernet4 VRF: PROD Level: 2 - Incorrect interface mode - Expected: point-to-point Actual: broadcast",
+ "Interface: Ethernet5 VRF: PROD Level: 2 - Not running in passive mode",
+ ],
+ },
+ },
+ {
+ "name": "skipped-not-configured",
"test": VerifyISISSegmentRoutingAdjacencySegments,
"eos_data": [{"vrfs": {}}],
"inputs": {
@@ -725,11 +1218,11 @@ DATA: list[dict[str, Any]] = [
}
]
},
- "expected": {"result": "skipped", "messages": ["IS-IS is not configured on device"]},
+ "expected": {"result": "skipped", "messages": ["IS-IS not configured"]},
},
{
"test": VerifyISISSegmentRoutingAdjacencySegments,
- "name": "Success of VerifyISISSegmentRoutingAdjacencySegments in default VRF.",
+ "name": "success",
"eos_data": [
{
"vrfs": {
@@ -807,7 +1300,7 @@ DATA: list[dict[str, Any]] = [
},
{
"test": VerifyISISSegmentRoutingAdjacencySegments,
- "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments in default VRF for incorrect segment definition.",
+ "name": "failure-segment-not-found",
"eos_data": [
{
"vrfs": {
@@ -885,95 +1378,12 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Your segment has not been found: interface='Ethernet3' level=2 sid_origin='dynamic' address=IPv4Address('10.0.1.2')."],
+ "messages": ["Instance: CORE-ISIS VRF: default Local Intf: Ethernet3 Adj IP Address: 10.0.1.2 - Adjacency segment not found"],
},
},
{
"test": VerifyISISSegmentRoutingAdjacencySegments,
- "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments with incorrect VRF.",
- "eos_data": [
- {
- "vrfs": {
- "default": {
- "isisInstances": {
- "CORE-ISIS": {
- "dataPlane": "MPLS",
- "routerId": "1.0.0.11",
- "systemId": "0168.0000.0011",
- "hostname": "s1-pe01",
- "adjSidAllocationMode": "SrOnly",
- "adjSidPoolBase": 116384,
- "adjSidPoolSize": 16384,
- "adjacencySegments": [
- {
- "ipAddress": "10.0.1.3",
- "localIntf": "Ethernet2",
- "sid": 116384,
- "lan": False,
- "sidOrigin": "dynamic",
- "protection": "unprotected",
- "flags": {
- "b": False,
- "v": True,
- "l": True,
- "f": False,
- "s": False,
- },
- "level": 2,
- },
- {
- "ipAddress": "10.0.1.1",
- "localIntf": "Ethernet1",
- "sid": 116385,
- "lan": False,
- "sidOrigin": "dynamic",
- "protection": "unprotected",
- "flags": {
- "b": False,
- "v": True,
- "l": True,
- "f": False,
- "s": False,
- },
- "level": 2,
- },
- ],
- "receivedGlobalAdjacencySegments": [],
- "misconfiguredAdjacencySegments": [],
- }
- }
- }
- }
- }
- ],
- "inputs": {
- "instances": [
- {
- "name": "CORE-ISIS",
- "vrf": "custom",
- "segments": [
- {
- "interface": "Ethernet2",
- "address": "10.0.1.3",
- "sid_origin": "dynamic",
- },
- {
- "interface": "Ethernet3",
- "address": "10.0.1.2",
- "sid_origin": "dynamic",
- },
- ],
- }
- ]
- },
- "expected": {
- "result": "failure",
- "messages": ["VRF custom is not configured to run segment routging."],
- },
- },
- {
- "test": VerifyISISSegmentRoutingAdjacencySegments,
- "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments with incorrect Instance.",
+ "name": "failure-no-segments-incorrect-instance",
"eos_data": [
{
"vrfs": {
@@ -1051,12 +1461,12 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Instance CORE-ISIS2 is not found in vrf default."],
+ "messages": ["Instance: CORE-ISIS2 VRF: default - No adjacency segments found"],
},
},
{
"test": VerifyISISSegmentRoutingAdjacencySegments,
- "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments with incorrect segment info.",
+ "name": "failure-incorrect-segment-level",
"eos_data": [
{
"vrfs": {
@@ -1112,20 +1522,79 @@ DATA: list[dict[str, Any]] = [
}
]
},
+ "expected": {
+ "result": "failure",
+ "messages": ["Instance: CORE-ISIS VRF: default Local Intf: Ethernet2 Adj IP Address: 10.0.1.3 - Incorrect IS-IS level - Expected: 1 Actual: 2"],
+ },
+ },
+ {
+ "test": VerifyISISSegmentRoutingAdjacencySegments,
+ "name": "failure-incorrect-sid-origin",
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "isisInstances": {
+ "CORE-ISIS": {
+ "dataPlane": "MPLS",
+ "routerId": "1.0.0.11",
+ "systemId": "0168.0000.0011",
+ "hostname": "s1-pe01",
+ "adjSidAllocationMode": "SrOnly",
+ "adjSidPoolBase": 116384,
+ "adjSidPoolSize": 16384,
+ "adjacencySegments": [
+ {
+ "ipAddress": "10.0.1.3",
+ "localIntf": "Ethernet2",
+ "sid": 116384,
+ "lan": False,
+ "sidOrigin": "configured",
+ "protection": "unprotected",
+ "flags": {
+ "b": False,
+ "v": True,
+ "l": True,
+ "f": False,
+ "s": False,
+ },
+ "level": 2,
+ },
+ ],
+ "receivedGlobalAdjacencySegments": [],
+ "misconfiguredAdjacencySegments": [],
+ }
+ }
+ }
+ }
+ }
+ ],
+ "inputs": {
+ "instances": [
+ {
+ "name": "CORE-ISIS",
+ "vrf": "default",
+ "segments": [
+ {
+ "interface": "Ethernet2",
+ "address": "10.0.1.3",
+ "sid_origin": "dynamic",
+ "level": 2, # Wrong level
+ },
+ ],
+ }
+ ]
+ },
"expected": {
"result": "failure",
"messages": [
- (
- "Your segment is not correct: Expected: interface='Ethernet2' level=1 sid_origin='dynamic' address=IPv4Address('10.0.1.3') - "
- "Found: {'ipAddress': '10.0.1.3', 'localIntf': 'Ethernet2', 'sid': 116384, 'lan': False, 'sidOrigin': 'dynamic', 'protection': "
- "'unprotected', 'flags': {'b': False, 'v': True, 'l': True, 'f': False, 's': False}, 'level': 2}."
- )
+ "Instance: CORE-ISIS VRF: default Local Intf: Ethernet2 Adj IP Address: 10.0.1.3 - Incorrect SID origin - Expected: dynamic Actual: configured"
],
},
},
{
"test": VerifyISISSegmentRoutingDataplane,
- "name": "Check VerifyISISSegmentRoutingDataplane is running successfully",
+ "name": "success",
"eos_data": [
{
"vrfs": {
@@ -1158,7 +1627,7 @@ DATA: list[dict[str, Any]] = [
},
{
"test": VerifyISISSegmentRoutingDataplane,
- "name": "Check VerifyISISSegmentRoutingDataplane is failing with incorrect dataplane",
+ "name": "failure-incorrect-dataplane",
"eos_data": [
{
"vrfs": {
@@ -1186,12 +1655,12 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["ISIS instance CORE-ISIS is not running dataplane unset (MPLS)"],
+ "messages": ["Instance: CORE-ISIS VRF: default - Data-plane not correctly configured - Expected: UNSET Actual: MPLS"],
},
},
{
"test": VerifyISISSegmentRoutingDataplane,
- "name": "Check VerifyISISSegmentRoutingDataplane is failing for unknown instance",
+ "name": "failure-instance-not-configured",
"eos_data": [
{
"vrfs": {
@@ -1219,58 +1688,25 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Instance CORE-ISIS2 is not found in vrf default."],
+ "messages": ["Instance: CORE-ISIS2 VRF: default - Not configured"],
},
},
{
"test": VerifyISISSegmentRoutingDataplane,
- "name": "Check VerifyISISSegmentRoutingDataplane is failing for unknown VRF",
- "eos_data": [
- {
- "vrfs": {
- "default": {
- "isisInstances": {
- "CORE-ISIS": {
- "dataPlane": "MPLS",
- "routerId": "1.0.0.11",
- "systemId": "0168.0000.0011",
- "hostname": "s1-pe01",
- }
- }
- }
- }
- }
- ],
- "inputs": {
- "instances": [
- {
- "name": "CORE-ISIS",
- "vrf": "wrong_vrf",
- "dataplane": "unset",
- },
- ]
- },
- "expected": {
- "result": "failure",
- "messages": ["VRF wrong_vrf is not configured to run segment routing."],
- },
- },
- {
- "test": VerifyISISSegmentRoutingDataplane,
- "name": "Check VerifyISISSegmentRoutingDataplane is skipped",
+ "name": "skipped-not-configured",
"eos_data": [{"vrfs": {}}],
"inputs": {
"instances": [
{
"name": "CORE-ISIS",
- "vrf": "wrong_vrf",
+ "vrf": "default",
"dataplane": "unset",
},
]
},
"expected": {
"result": "skipped",
- "messages": ["IS-IS-SR is not running on device"],
+ "messages": ["IS-IS not configured"],
},
},
{
@@ -1376,7 +1812,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "skipped",
- "messages": ["IS-IS-SR is not running on device."],
+ "messages": ["IS-IS-SR not configured"],
},
},
{
@@ -1405,7 +1841,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Tunnel to endpoint=IPv4Network('1.0.0.122/32') vias=None is not found."],
+ "messages": ["Endpoint: 1.0.0.122/32 - Tunnel not found"],
},
},
{
@@ -1486,7 +1922,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Tunnel to 1.0.0.13/32 is incorrect: incorrect tunnel type"],
+ "messages": ["Endpoint: 1.0.0.13/32 Type: tunnel - Tunnel is incorrect"],
},
},
{
@@ -1574,7 +2010,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Tunnel to 1.0.0.122/32 is incorrect: incorrect nexthop"],
+ "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.2 Type: ip Interface: Ethernet1 - Tunnel is incorrect"],
},
},
{
@@ -1662,7 +2098,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Tunnel to 1.0.0.122/32 is incorrect: incorrect interface"],
+ "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.1 Type: ip Interface: Ethernet4 - Tunnel is incorrect"],
},
},
{
@@ -1750,7 +2186,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Tunnel to 1.0.0.122/32 is incorrect: incorrect nexthop"],
+ "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.2 Type: ip Interface: Ethernet1 - Tunnel is incorrect"],
},
},
{
@@ -1815,7 +2251,7 @@ DATA: list[dict[str, Any]] = [
"vias": [
{
"type": "tunnel",
- "tunnelId": {"type": "TI-LFA", "index": 4},
+ "tunnelId": {"type": "unset", "index": 4},
"labels": ["3"],
}
],
@@ -1830,89 +2266,35 @@ DATA: list[dict[str, Any]] = [
{
"endpoint": "1.0.0.111/32",
"vias": [
- {"type": "tunnel", "tunnel_id": "unset"},
+ {"type": "tunnel", "tunnel_id": "ti-lfa"},
],
},
]
},
"expected": {
"result": "failure",
- "messages": ["Tunnel to 1.0.0.111/32 is incorrect: incorrect tunnel ID"],
+ "messages": ["Endpoint: 1.0.0.111/32 Type: tunnel Tunnel ID: ti-lfa - Tunnel is incorrect"],
+ },
+ },
+ {
+ "test": VerifyISISSegmentRoutingTunnels,
+ "name": "skipped with ISIS-SR not running",
+ "eos_data": [{"entries": {}}],
+ "inputs": {
+ "entries": [
+ {"endpoint": "1.0.0.122/32"},
+ {"endpoint": "1.0.0.13/32", "vias": [{"type": "ip"}]},
+ {
+ "endpoint": "1.0.0.111/32",
+ "vias": [
+ {"type": "tunnel", "tunnel_id": "unset"},
+ ],
+ },
+ ]
+ },
+ "expected": {
+ "result": "skipped",
+ "messages": ["IS-IS-SR not configured"],
},
},
]
-
-
-COMMAND_OUTPUT = {
- "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,
- },
- }
- }
- }
- },
- "EMPTY": {"isisInstances": {}},
- "NO_INTERFACES": {"isisInstances": {"CORE-ISIS": {}}},
- }
-}
-EXPECTED_LOOPBACK_0_OUTPUT = {
- "enabled": True,
- "intfLevels": {
- "2": {
- "ipv4Metric": 10,
- "sharedSecretProfile": "",
- "isisAdjacencies": [],
- "passive": True,
- "v4Protection": "disabled",
- "v6Protection": "disabled",
- }
- },
- "areaProxyBoundary": False,
-}
-
-
-@pytest.mark.parametrize(
- ("interface", "vrf", "expected_value"),
- [
- pytest.param("Loopback0", "WRONG_VRF", None, id="VRF_not_found"),
- pytest.param("Loopback0", "EMPTY", None, id="VRF_no_ISIS_instances"),
- pytest.param("Loopback0", "NO_INTERFACES", None, id="ISIS_instance_no_interfaces"),
- pytest.param("Loopback42", "default", None, id="interface_not_found"),
- pytest.param("Loopback0", "default", EXPECTED_LOOPBACK_0_OUTPUT, id="interface_found"),
- ],
-)
-def test__get_interface_data(interface: str, vrf: str, expected_value: dict[str, Any] | None) -> None:
- """Test anta.tests.routing.isis._get_interface_data."""
- assert _get_interface_data(interface, vrf, COMMAND_OUTPUT) == expected_value
diff --git a/tests/units/anta_tests/routing/test_ospf.py b/tests/units/anta_tests/routing/test_ospf.py
index 1555af6..0c736dc 100644
--- a/tests/units/anta_tests/routing/test_ospf.py
+++ b/tests/units/anta_tests/routing/test_ospf.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -122,13 +122,13 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Some neighbors are not correctly configured: [{'vrf': 'default', 'instance': '666', 'neighbor': '7.7.7.7', 'state': '2-way'},"
- " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}].",
+ "Instance: 666 VRF: default Interface: 7.7.7.7 - Incorrect adjacency state - Expected: Full Actual: 2-way",
+ "Instance: 777 VRF: BLAH Interface: 8.8.8.8 - Incorrect adjacency state - Expected: Full Actual: down",
],
},
},
{
- "name": "skipped",
+ "name": "skipped-ospf-not-configured",
"test": VerifyOSPFNeighborState,
"eos_data": [
{
@@ -136,7 +136,33 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]},
+ "expected": {"result": "skipped", "messages": ["OSPF not configured"]},
+ },
+ {
+ "name": "skipped-neighbor-not-found",
+ "test": VerifyOSPFNeighborState,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "instList": {
+ "666": {
+ "ospfNeighborEntries": [],
+ },
+ },
+ },
+ "BLAH": {
+ "instList": {
+ "777": {
+ "ospfNeighborEntries": [],
+ },
+ },
+ },
+ },
+ },
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["No OSPF neighbor detected"]},
},
{
"name": "success",
@@ -193,35 +219,6 @@ DATA: list[dict[str, Any]] = [
"inputs": {"number": 3},
"expected": {"result": "success"},
},
- {
- "name": "failure-wrong-number",
- "test": VerifyOSPFNeighborCount,
- "eos_data": [
- {
- "vrfs": {
- "default": {
- "instList": {
- "666": {
- "ospfNeighborEntries": [
- {
- "routerId": "7.7.7.7",
- "priority": 1,
- "drState": "DR",
- "interfaceName": "Ethernet1",
- "adjacencyState": "full",
- "inactivity": 1683298014.844345,
- "interfaceAddress": "10.3.0.1",
- },
- ],
- },
- },
- },
- },
- },
- ],
- "inputs": {"number": 3},
- "expected": {"result": "failure", "messages": ["device has 1 neighbors (expected 3)"]},
- },
{
"name": "failure-good-number-wrong-state",
"test": VerifyOSPFNeighborCount,
@@ -277,14 +274,11 @@ DATA: list[dict[str, Any]] = [
"inputs": {"number": 3},
"expected": {
"result": "failure",
- "messages": [
- "Some neighbors are not correctly configured: [{'vrf': 'default', 'instance': '666', 'neighbor': '7.7.7.7', 'state': '2-way'},"
- " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}].",
- ],
+ "messages": ["Neighbor count mismatch - Expected: 3 Actual: 1"],
},
},
{
- "name": "skipped",
+ "name": "skipped-ospf-not-configured",
"test": VerifyOSPFNeighborCount,
"eos_data": [
{
@@ -292,7 +286,38 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"number": 3},
- "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]},
+ "expected": {"result": "skipped", "messages": ["OSPF not configured"]},
+ },
+ {
+ "name": "skipped-no-neighbor-detected",
+ "test": VerifyOSPFNeighborCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "instList": {
+ "666": {
+ "ospfNeighborEntries": [],
+ },
+ },
+ },
+ "BLAH": {
+ "instList": {
+ "777": {
+ "ospfNeighborEntries": [],
+ },
+ },
+ },
+ },
+ },
+ ],
+ "inputs": {"number": 3},
+ "expected": {
+ "result": "skipped",
+ "messages": [
+ "No OSPF neighbor detected",
+ ],
+ },
},
{
"name": "success",
@@ -394,7 +419,10 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["OSPF Instances ['1', '10'] crossed the maximum LSA threshold."],
+ "messages": [
+ "Instance: 1 - Crossed the maximum LSA threshold - Expected: < 9000 Actual: 11500",
+ "Instance: 10 - Crossed the maximum LSA threshold - Expected: < 750 Actual: 1500",
+ ],
},
},
{
@@ -406,6 +434,6 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "skipped", "messages": ["No OSPF instance found."]},
+ "expected": {"result": "skipped", "messages": ["OSPF not configured"]},
},
]
diff --git a/tests/units/anta_tests/test_aaa.py b/tests/units/anta_tests/test_aaa.py
index 119e206..c91f051 100644
--- a/tests/units/anta_tests/test_aaa.py
+++ b/tests/units/anta_tests/test_aaa.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.aaa.py."""
@@ -47,7 +47,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"intf": "Management0", "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT Source Interface: Management0 - Not configured"]},
},
{
"name": "failure-wrong-intf",
@@ -64,7 +64,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"intf": "Management0", "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Wrong source-interface configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Source interface mismatch - Expected: Management0 Actual: Management1"]},
},
{
"name": "failure-wrong-vrf",
@@ -81,7 +81,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"intf": "Management0", "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT Source Interface: Management0 - Not configured"]},
},
{
"name": "success",
@@ -128,7 +128,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"servers": ["10.22.10.91", "10.22.10.92"], "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.92'] are not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["TACACS servers 10.22.10.92 are not configured in VRF MGMT"]},
},
{
"name": "failure-wrong-vrf",
@@ -145,7 +145,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.91'] are not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["TACACS servers 10.22.10.91 are not configured in VRF MGMT"]},
},
{
"name": "success",
@@ -192,7 +192,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"groups": ["GROUP1"]},
- "expected": {"result": "failure", "messages": ["TACACS server group(s) ['GROUP1'] are not configured"]},
+ "expected": {"result": "failure", "messages": ["TACACS server group(s) GROUP1 are not configured"]},
},
{
"name": "success-login-enable",
@@ -244,7 +244,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]},
- "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for login console"]},
+ "expected": {"result": "failure", "messages": ["AAA authentication methods group tacacs+, local are not matching for login console"]},
},
{
"name": "failure-login-default",
@@ -257,7 +257,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]},
- "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for ['login']"]},
+ "expected": {"result": "failure", "messages": ["AAA authentication methods group tacacs+, local are not matching for login"]},
},
{
"name": "success",
@@ -293,7 +293,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
- "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA authorization methods group tacacs+, local are not matching for commands"]},
},
{
"name": "failure-exec",
@@ -305,7 +305,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
- "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['exec']"]},
+ "expected": {"result": "failure", "messages": ["AAA authorization methods group tacacs+, local are not matching for exec"]},
},
{
"name": "success-commands-exec-system",
@@ -347,7 +347,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
- "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for commands"]},
},
{
"name": "failure-not-configured-empty",
@@ -361,7 +361,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
- "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['system', 'exec', 'commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for system, exec, commands"]},
},
{
"name": "failure-not-matching",
@@ -375,7 +375,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
- "expected": {"result": "failure", "messages": ["AAA accounting default methods ['group tacacs+', 'logging'] are not matching for ['commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA accounting default methods group tacacs+, logging are not matching for commands"]},
},
{
"name": "success-commands-exec-system",
@@ -476,7 +476,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
- "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for commands"]},
},
{
"name": "failure-not-configured-empty",
@@ -490,7 +490,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
- "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['system', 'exec', 'commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for system, exec, commands"]},
},
{
"name": "failure-not-matching",
@@ -522,6 +522,6 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
- "expected": {"result": "failure", "messages": ["AAA accounting console methods ['group tacacs+', 'logging'] are not matching for ['commands']"]},
+ "expected": {"result": "failure", "messages": ["AAA accounting console methods group tacacs+, logging are not matching for commands"]},
},
]
diff --git a/tests/units/anta_tests/test_avt.py b/tests/units/anta_tests/test_avt.py
index d9cdaa1..39c5e1e 100644
--- a/tests/units/anta_tests/test_avt.py
+++ b/tests/units/anta_tests/test_avt.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -94,7 +94,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {},
"expected": {
"result": "failure",
- "messages": ["Adaptive virtual topology paths are not configured."],
+ "messages": ["Adaptive virtual topology paths are not configured"],
},
},
{
@@ -174,9 +174,9 @@ DATA: list[dict[str, Any]] = [
"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.",
+ "VRF: guest Profile: GUEST-AVT-POLICY-DEFAULT AVT path: direct:10 - Not active",
+ "VRF: default Profile: CONTROL-PLANE-PROFILE AVT path: direct:1 - Not active",
+ "VRF: default Profile: DEFAULT-AVT-POLICY-DEFAULT AVT path: direct:10 - Not active",
],
},
},
@@ -257,10 +257,10 @@ DATA: list[dict[str, Any]] = [
"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.",
+ "VRF: data Profile: DATA-AVT-POLICY-DEFAULT AVT path: direct:10 - Invalid",
+ "VRF: guest Profile: GUEST-AVT-POLICY-DEFAULT AVT path: direct:8 - Invalid",
+ "VRF: default Profile: CONTROL-PLANE-PROFILE AVT path: direct:10 - Invalid",
+ "VRF: default Profile: DEFAULT-AVT-POLICY-DEFAULT AVT path: direct:8 - Invalid",
],
},
},
@@ -341,13 +341,13 @@ DATA: list[dict[str, Any]] = [
"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.",
+ "VRF: data Profile: DATA-AVT-POLICY-DEFAULT AVT path: direct:10 - Invalid and not active",
+ "VRF: data Profile: DATA-AVT-POLICY-DEFAULT AVT path: direct:1 - Not active",
+ "VRF: guest Profile: GUEST-AVT-POLICY-DEFAULT AVT path: direct:10 - Invalid",
+ "VRF: guest Profile: GUEST-AVT-POLICY-DEFAULT AVT path: direct:8 - Invalid and not active",
+ "VRF: default Profile: CONTROL-PLANE-PROFILE AVT path: direct:10 - Invalid and not active",
+ "VRF: default Profile: DEFAULT-AVT-POLICY-DEFAULT AVT path: direct:10 - Not active",
+ "VRF: default Profile: DEFAULT-AVT-POLICY-DEFAULT AVT path: direct:8 - Invalid and not active",
],
},
},
@@ -444,7 +444,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["AVT MGMT-AVT-POLICY-DEFAULT VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1) - No AVT path configured"],
+ "messages": ["AVT: MGMT-AVT-POLICY-DEFAULT VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.1 - No AVT path configured"],
},
},
{
@@ -507,8 +507,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AVT DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.11) Path Type: multihop - Path not found",
- "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.21) Path Type: direct - Path not found",
+ "AVT: DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.11 Path Type: multihop - Path not found",
+ "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.21 Path Type: direct - Path not found",
],
},
},
@@ -571,8 +571,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AVT DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.11) - Path not found",
- "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.21) - Path not found",
+ "AVT: DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.11 - Path not found",
+ "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.21 - Path not found",
],
},
},
@@ -646,12 +646,12 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "AVT DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1) - "
- "Incorrect path multihop:3 - Valid: False, Active: True",
- "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.1) - "
- "Incorrect path direct:10 - Valid: False, Active: True",
- "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.1) - "
- "Incorrect path direct:9 - Valid: True, Active: False",
+ "AVT: DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.1 - "
+ "Incorrect path multihop:3 - Valid: False Active: True",
+ "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.1 - "
+ "Incorrect path direct:10 - Valid: False Active: True",
+ "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.1 - "
+ "Incorrect path direct:9 - Valid: True Active: False",
],
},
},
@@ -667,6 +667,6 @@ DATA: list[dict[str, Any]] = [
"test": VerifyAVTRole,
"eos_data": [{"role": "transit"}],
"inputs": {"role": "edge"},
- "expected": {"result": "failure", "messages": ["Expected AVT role as `edge`, but found `transit` instead."]},
+ "expected": {"result": "failure", "messages": ["AVT role mismatch - Expected: edge Actual: transit"]},
},
]
diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py
index 952e838..dd67397 100644
--- a/tests/units/anta_tests/test_bfd.py
+++ b/tests/units/anta_tests/test_bfd.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.bfd.py."""
@@ -27,6 +27,7 @@ DATA: list[dict[str, Any]] = [
"operTxInterval": 1200000,
"operRxInterval": 1200000,
"detectMult": 3,
+ "detectTime": 3600000,
}
}
}
@@ -42,6 +43,7 @@ DATA: list[dict[str, Any]] = [
"operTxInterval": 1200000,
"operRxInterval": 1200000,
"detectMult": 3,
+ "detectTime": 3600000,
}
}
}
@@ -59,6 +61,55 @@ DATA: list[dict[str, Any]] = [
},
"expected": {"result": "success"},
},
+ {
+ "name": "success-detection-time",
+ "test": VerifyBFDPeersIntervals,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1200000,
+ "operRxInterval": 1200000,
+ "detectMult": 3,
+ "detectTime": 3600000,
+ }
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1200000,
+ "operRxInterval": 1200000,
+ "detectMult": 3,
+ "detectTime": 3600000,
+ }
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bfd_peers": [
+ {"peer_address": "192.0.255.7", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3, "detection_time": 3600},
+ {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3, "detection_time": 3600},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
{
"name": "failure-no-peer",
"test": VerifyBFDPeersIntervals,
@@ -74,6 +125,7 @@ DATA: list[dict[str, Any]] = [
"operTxInterval": 1200000,
"operRxInterval": 1200000,
"detectMult": 3,
+ "detectTime": 3600000,
}
}
}
@@ -89,6 +141,7 @@ DATA: list[dict[str, Any]] = [
"operTxInterval": 1200000,
"operRxInterval": 1200000,
"detectMult": 3,
+ "detectTime": 3600000,
}
}
}
@@ -100,8 +153,8 @@ DATA: list[dict[str, Any]] = [
],
"inputs": {
"bfd_peers": [
- {"peer_address": "192.0.255.7", "vrf": "CS", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3},
- {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3},
+ {"peer_address": "192.0.255.7", "vrf": "CS", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3, "detection_time": 3600},
+ {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3, "detection_time": 3600},
]
},
"expected": {
@@ -127,6 +180,7 @@ DATA: list[dict[str, Any]] = [
"operTxInterval": 1300000,
"operRxInterval": 1200000,
"detectMult": 4,
+ "detectTime": 4000000,
}
}
}
@@ -142,6 +196,7 @@ DATA: list[dict[str, Any]] = [
"operTxInterval": 120000,
"operRxInterval": 120000,
"detectMult": 5,
+ "detectTime": 4000000,
}
}
}
@@ -168,6 +223,66 @@ DATA: list[dict[str, Any]] = [
],
},
},
+ {
+ "name": "failure-incorrect-timers-with-detection-time",
+ "test": VerifyBFDPeersIntervals,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1300000,
+ "operRxInterval": 1200000,
+ "detectMult": 4,
+ "detectTime": 4000000,
+ }
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 120000,
+ "operRxInterval": 120000,
+ "detectMult": 5,
+ "detectTime": 4000000,
+ }
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bfd_peers": [
+ {"peer_address": "192.0.255.7", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3, "detection_time": 3600},
+ {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3, "detection_time": 3600},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Peer: 192.0.255.7 VRF: default - Incorrect Transmit interval - Expected: 1200 Actual: 1300",
+ "Peer: 192.0.255.7 VRF: default - Incorrect Multiplier - Expected: 3 Actual: 4",
+ "Peer: 192.0.255.7 VRF: default - Incorrect Detection Time - Expected: 3600 Actual: 4000",
+ "Peer: 192.0.255.70 VRF: MGMT - Incorrect Transmit interval - Expected: 1200 Actual: 120",
+ "Peer: 192.0.255.70 VRF: MGMT - Incorrect Receive interval - Expected: 1200 Actual: 120",
+ "Peer: 192.0.255.70 VRF: MGMT - Incorrect Multiplier - Expected: 3 Actual: 5",
+ "Peer: 192.0.255.70 VRF: MGMT - Incorrect Detection Time - Expected: 3600 Actual: 4000",
+ ],
+ },
+ },
{
"name": "success",
"test": VerifyBFDSpecificPeers,
@@ -356,7 +471,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["No IPv4 BFD peers are configured for any VRF."],
+ "messages": ["No IPv4 BFD peers are configured for any VRF"],
},
},
{
@@ -622,7 +737,7 @@ DATA: list[dict[str, Any]] = [
"result": "failure",
"messages": [
"Peer: 192.0.255.7 VRF: default - `isis` routing protocol(s) not configured",
- "Peer: 192.0.255.70 VRF: MGMT - `isis` `ospf` routing protocol(s) not configured",
+ "Peer: 192.0.255.70 VRF: MGMT - `isis`, `ospf` routing protocol(s) not configured",
],
},
},
diff --git a/tests/units/anta_tests/test_configuration.py b/tests/units/anta_tests/test_configuration.py
index d8f86be..fd48337 100644
--- a/tests/units/anta_tests/test_configuration.py
+++ b/tests/units/anta_tests/test_configuration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for testing anta.tests.configuration."""
@@ -58,6 +58,6 @@ DATA: list[dict[str, Any]] = [
"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'"]},
+ "expected": {"result": "failure", "messages": ["Following patterns were not found: 'bla', 'bleh'"]},
},
]
diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py
index eac3084..7fb9a11 100644
--- a/tests/units/anta_tests/test_connectivity.py
+++ b/tests/units/anta_tests/test_connectivity.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.connectivity.py."""
@@ -45,6 +45,63 @@ DATA: list[dict[str, Any]] = [
],
"expected": {"result": "success"},
},
+ {
+ "name": "success-expected-unreachable",
+ "test": VerifyReachability,
+ "eos_data": [
+ {
+ "messages": [
+ """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data.
+
+ --- 10.0.0.1 ping statistics ---
+ 2 packets transmitted, 0 received, 100% packet loss, time 10ms
+ """,
+ ],
+ },
+ ],
+ "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "10.0.0.5", "reachable": False}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-ipv6",
+ "test": VerifyReachability,
+ "eos_data": [
+ {
+ "messages": [
+ """PING fd12:3456:789a:1::2(fd12:3456:789a:1::2) from fd12:3456:789a:1::1 : 52 data bytes
+ 60 bytes from fd12:3456:789a:1::2: icmp_seq=1 ttl=64 time=0.097 ms
+ 60 bytes from fd12:3456:789a:1::2: icmp_seq=2 ttl=64 time=0.033 ms
+
+ --- fd12:3456:789a:1::2 ping statistics ---
+ 2 packets transmitted, 2 received, 0% packet loss, time 0ms
+ rtt min/avg/max/mdev = 0.033/0.065/0.097/0.032 ms, ipg/ewma 0.148/0.089 ms
+ """,
+ ],
+ },
+ ],
+ "inputs": {"hosts": [{"destination": "fd12:3456:789a:1::2", "source": "fd12:3456:789a:1::1"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-ipv6-vlan",
+ "test": VerifyReachability,
+ "eos_data": [
+ {
+ "messages": [
+ """PING fd12:3456:789a:1::2(fd12:3456:789a:1::2) 52 data bytes
+ 60 bytes from fd12:3456:789a:1::2: icmp_seq=1 ttl=64 time=0.094 ms
+ 60 bytes from fd12:3456:789a:1::2: icmp_seq=2 ttl=64 time=0.027 ms
+
+ --- fd12:3456:789a:1::2 ping statistics ---
+ 2 packets transmitted, 2 received, 0% packet loss, time 0ms
+ rtt min/avg/max/mdev = 0.027/0.060/0.094/0.033 ms, ipg/ewma 0.152/0.085 ms
+ """,
+ ],
+ },
+ ],
+ "inputs": {"hosts": [{"destination": "fd12:3456:789a:1::2", "source": "vl110"}]},
+ "expected": {"result": "success"},
+ },
{
"name": "success-interface",
"test": VerifyReachability,
@@ -153,7 +210,24 @@ DATA: list[dict[str, Any]] = [
],
},
],
- "expected": {"result": "failure", "messages": ["Host 10.0.0.11 (src: 10.0.0.5, vrf: default, size: 100B, repeat: 2) - Unreachable"]},
+ "expected": {"result": "failure", "messages": ["Host: 10.0.0.11 Source: 10.0.0.5 VRF: default - Unreachable"]},
+ },
+ {
+ "name": "failure-ipv6",
+ "test": VerifyReachability,
+ "eos_data": [
+ {
+ "messages": [
+ """PING fd12:3456:789a:1::2(fd12:3456:789a:1::2) from fd12:3456:789a:1::1 : 52 data bytes
+
+ --- fd12:3456:789a:1::3 ping statistics ---
+ 2 packets transmitted, 0 received, 100% packet loss, time 10ms
+ """,
+ ],
+ },
+ ],
+ "inputs": {"hosts": [{"destination": "fd12:3456:789a:1::2", "source": "fd12:3456:789a:1::1"}]},
+ "expected": {"result": "failure", "messages": ["Host: fd12:3456:789a:1::2 Source: fd12:3456:789a:1::1 VRF: default - Unreachable"]},
},
{
"name": "failure-interface",
@@ -187,7 +261,7 @@ DATA: list[dict[str, Any]] = [
],
},
],
- "expected": {"result": "failure", "messages": ["Host 10.0.0.11 (src: Management0, vrf: default, size: 100B, repeat: 2) - Unreachable"]},
+ "expected": {"result": "failure", "messages": ["Host: 10.0.0.11 Source: Management0 VRF: default - Unreachable"]},
},
{
"name": "failure-size",
@@ -209,7 +283,31 @@ DATA: list[dict[str, Any]] = [
],
},
],
- "expected": {"result": "failure", "messages": ["Host 10.0.0.1 (src: Management0, vrf: default, size: 1501B, repeat: 5, df-bit: enabled) - Unreachable"]},
+ "expected": {"result": "failure", "messages": ["Host: 10.0.0.1 Source: Management0 VRF: default - Unreachable"]},
+ },
+ {
+ "name": "failure-expected-unreachable",
+ "test": VerifyReachability,
+ "eos_data": [
+ {
+ "messages": [
+ """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data.
+ 80 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.247 ms
+ 80 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.072 ms
+
+ --- 10.0.0.1 ping statistics ---
+ 2 packets transmitted, 2 received, 0% packet loss, time 0ms
+ rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms
+
+ """,
+ ],
+ },
+ ],
+ "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "10.0.0.5", "reachable": False}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["Host: 10.0.0.1 Source: 10.0.0.5 VRF: default - Destination is expected to be unreachable but found reachable"],
+ },
},
{
"name": "success",
@@ -330,7 +428,7 @@ DATA: list[dict[str, Any]] = [
{"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
],
},
- "expected": {"result": "failure", "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - Port not found"]},
+ "expected": {"result": "failure", "messages": ["Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - Port not found"]},
},
{
"name": "failure-no-neighbor",
@@ -363,7 +461,7 @@ DATA: list[dict[str, Any]] = [
{"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
],
},
- "expected": {"result": "failure", "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - No LLDP neighbors"]},
+ "expected": {"result": "failure", "messages": ["Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - No LLDP neighbors"]},
},
{
"name": "failure-wrong-neighbor",
@@ -412,7 +510,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE2/Ethernet2"],
+ "messages": ["Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: DC1-SPINE2/Ethernet2"],
},
},
{
@@ -450,9 +548,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Port Ethernet1 (Neighbor: DC1-SPINE1, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE1/Ethernet2",
- "Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - No LLDP neighbors",
- "Port Ethernet3 (Neighbor: DC1-SPINE3, Neighbor Port: Ethernet1) - Port not found",
+ "Port: Ethernet1 Neighbor: DC1-SPINE1 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: DC1-SPINE1/Ethernet2",
+ "Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - No LLDP neighbors",
+ "Port: Ethernet3 Neighbor: DC1-SPINE3 Neighbor Port: Ethernet1 - Port not found",
],
},
},
@@ -498,7 +596,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Port Ethernet1 (Neighbor: DC1-SPINE3, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE1/Ethernet1, DC1-SPINE2/Ethernet1"],
+ "messages": ["Port: Ethernet1 Neighbor: DC1-SPINE3 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: DC1-SPINE1/Ethernet1, DC1-SPINE2/Ethernet1"],
},
},
]
diff --git a/tests/units/anta_tests/test_cvx.py b/tests/units/anta_tests/test_cvx.py
index 46d83b0..be62293 100644
--- a/tests/units/anta_tests/test_cvx.py
+++ b/tests/units/anta_tests/test_cvx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for testing anta.tests.cvx."""
@@ -60,7 +60,10 @@ DATA: list[dict[str, Any]] = [
"test": VerifyMcsClientMounts,
"eos_data": [{"mountStates": [{"path": "mcs/v1/toSwitch/28-99-3a-8f-93-7b", "type": "Mcs::DeviceConfigV1", "state": "mountStatePreservedUnmounted"}]}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]},
+ "expected": {
+ "result": "failure",
+ "messages": ["MCS Client mount states are not valid - Expected: mountStateMountComplete Actual: mountStatePreservedUnmounted"],
+ },
},
{
"name": "failure-partial-haclient",
@@ -74,7 +77,10 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]},
+ "expected": {
+ "result": "failure",
+ "messages": ["MCS Client mount states are not valid - Expected: mountStateMountComplete Actual: mountStatePreservedUnmounted"],
+ },
},
{
"name": "failure-full-haclient",
@@ -88,7 +94,10 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]},
+ "expected": {
+ "result": "failure",
+ "messages": ["MCS Client mount states are not valid - Expected: mountStateMountComplete Actual: mountStatePreservedUnmounted"],
+ },
},
{
"name": "failure-non-mcs-client",
@@ -111,7 +120,10 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]},
+ "expected": {
+ "result": "failure",
+ "messages": ["MCS Client mount states are not valid - Expected: mountStateMountComplete Actual: mountStatePreservedUnmounted"],
+ },
},
{
"name": "success-enabled",
@@ -140,18 +152,31 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "failure - no enabled state",
+ "name": "failure-invalid-state",
+ "test": VerifyManagementCVX,
+ "eos_data": [
+ {
+ "clusterStatus": {
+ "enabled": False,
+ }
+ }
+ ],
+ "inputs": {"enabled": True},
+ "expected": {"result": "failure", "messages": ["Management CVX status is not valid: Expected: enabled Actual: disabled"]},
+ },
+ {
+ "name": "failure-no-enabled state",
"test": VerifyManagementCVX,
"eos_data": [{"clusterStatus": {}}],
"inputs": {"enabled": False},
- "expected": {"result": "failure", "messages": ["Management CVX status is not valid: None"]},
+ "expected": {"result": "failure", "messages": ["Management CVX status - Not configured"]},
},
{
"name": "failure - no clusterStatus",
"test": VerifyManagementCVX,
"eos_data": [{}],
"inputs": {"enabled": False},
- "expected": {"result": "failure", "messages": ["Management CVX status is not valid: None"]},
+ "expected": {"result": "failure", "messages": ["Management CVX status - Not configured"]},
},
{
"name": "success",
@@ -189,7 +214,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"connections_count": 1},
"expected": {
"result": "failure",
- "messages": ["No mount status for media-leaf-1", "Incorrect CVX successful connections count. Expected: 1, Actual : 0"],
+ "messages": ["Host: media-leaf-1 - No mount status found", "Incorrect CVX successful connections count - Expected: 1 Actual: 0"],
},
},
{
@@ -221,8 +246,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Incorrect number of mount path states for media-leaf-1 - Expected: 3, Actual: 2",
- "Unexpected MCS path type for media-leaf-1: 'Mcs::ApiStatus'.",
+ "Host: media-leaf-1 - Incorrect number of mount path states - Expected: 3 Actual: 2",
+ "Host: media-leaf-1 - Unexpected MCS path type - Expected: Mcs::ApiConfigRedundancyStatus, Mcs::ActiveFlows, "
+ "Mcs::Client::Status Actual: Mcs::ApiStatus",
],
},
},
@@ -253,7 +279,13 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {"connections_count": 1},
- "expected": {"result": "failure", "messages": ["Unexpected MCS path type for media-leaf-1: 'Mcs::ApiStatus'"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Host: media-leaf-1 - Unexpected MCS path type - Expected: Mcs::ApiConfigRedundancyStatus, Mcs::ActiveFlows, Mcs::Client::Status"
+ " Actual: Mcs::ApiStatus"
+ ],
+ },
},
{
"name": "failure-invalid-mount-state",
@@ -284,7 +316,10 @@ DATA: list[dict[str, Any]] = [
"inputs": {"connections_count": 1},
"expected": {
"result": "failure",
- "messages": ["MCS server mount state for path 'Mcs::ApiConfigRedundancyStatus' is not valid is for media-leaf-1: 'mountStateMountFailed'"],
+ "messages": [
+ "Host: media-leaf-1 Path Type: Mcs::ApiConfigRedundancyStatus - MCS server mount state is not valid - Expected: mountStateMountComplete"
+ " Actual:mountStateMountFailed"
+ ],
},
},
{
@@ -306,14 +341,14 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {"connections_count": 1},
- "expected": {"result": "failure", "messages": ["MCS mount state not detected", "Incorrect CVX successful connections count. Expected: 1, Actual : 0"]},
+ "expected": {"result": "failure", "messages": ["MCS mount state not detected", "Incorrect CVX successful connections count - Expected: 1 Actual: 0"]},
},
{
"name": "failure-connections",
"test": VerifyMcsServerMounts,
"eos_data": [{}],
"inputs": {"connections_count": 1},
- "expected": {"result": "failure", "messages": ["CVX connections are not available."]},
+ "expected": {"result": "failure", "messages": ["CVX connections are not available"]},
},
{
"name": "success",
@@ -357,7 +392,7 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {"connections_count": 2},
- "expected": {"result": "failure", "messages": ["CVX active connections count. Expected: 2, Actual : 1"]},
+ "expected": {"result": "failure", "messages": ["CVX active connections count - Expected: 2 Actual: 1"]},
},
{
"name": "failure-no-connections",
@@ -414,7 +449,7 @@ DATA: list[dict[str, Any]] = [
{"peer_name": "cvx-red-3", "registrationState": "Registration complete"},
],
},
- "expected": {"result": "failure", "messages": ["CVX Role is not valid: Standby"]},
+ "expected": {"result": "failure", "messages": ["CVX Role is not valid: Expected: Master Actual: Standby"]},
},
{
"name": "failure-cvx-enabled",
@@ -473,7 +508,7 @@ DATA: list[dict[str, Any]] = [
{"peer_name": "cvx-red-3", "registrationState": "Registration complete"},
],
},
- "expected": {"result": "failure", "messages": ["Unexpected number of peers 1 vs 2", "cvx-red-3 is not present"]},
+ "expected": {"result": "failure", "messages": ["Unexpected number of peers - Expected: 2 Actual: 1", "cvx-red-3 - Not present"]},
},
{
"name": "failure-invalid-peers",
@@ -495,7 +530,7 @@ DATA: list[dict[str, Any]] = [
{"peer_name": "cvx-red-3", "registrationState": "Registration complete"},
],
},
- "expected": {"result": "failure", "messages": ["Unexpected number of peers 0 vs 2", "cvx-red-2 is not present", "cvx-red-3 is not present"]},
+ "expected": {"result": "failure", "messages": ["Unexpected number of peers - Expected: 2 Actual: 0", "cvx-red-2 - Not present", "cvx-red-3 - Not present"]},
},
{
"name": "failure-registration-error",
@@ -520,6 +555,6 @@ DATA: list[dict[str, Any]] = [
{"peer_name": "cvx-red-3", "registrationState": "Registration complete"},
],
},
- "expected": {"result": "failure", "messages": ["cvx-red-2 registration state is not complete: Registration error"]},
+ "expected": {"result": "failure", "messages": ["cvx-red-2 - Invalid registration state - Expected: Registration complete Actual: Registration error"]},
},
]
diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py
index 8e7c9d8..f7c5fc6 100644
--- a/tests/units/anta_tests/test_field_notices.py
+++ b/tests/units/anta_tests/test_field_notices.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.field_notices."""
@@ -45,7 +45,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["device is running incorrect version of aboot (4.0.1)"],
+ "messages": ["Device is running incorrect version of aboot 4.0.1"],
},
},
{
@@ -65,7 +65,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["device is running incorrect version of aboot (4.1.0)"],
+ "messages": ["Device is running incorrect version of aboot 4.1.0"],
},
},
{
@@ -85,7 +85,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["device is running incorrect version of aboot (6.0.1)"],
+ "messages": ["Device is running incorrect version of aboot 6.0.1"],
},
},
{
@@ -105,7 +105,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["device is running incorrect version of aboot (6.1.1)"],
+ "messages": ["Device is running incorrect version of aboot 6.1.1"],
},
},
{
@@ -125,7 +125,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "skipped",
- "messages": ["device is not impacted by FN044"],
+ "messages": ["Device is not impacted by FN044"],
},
},
{
diff --git a/tests/units/anta_tests/test_flow_tracking.py b/tests/units/anta_tests/test_flow_tracking.py
index f50a76b..cdd2746 100644
--- a/tests/units/anta_tests/test_flow_tracking.py
+++ b/tests/units/anta_tests/test_flow_tracking.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.flow_tracking."""
@@ -22,18 +22,13 @@ DATA: list[dict[str, Any]] = [
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-TRACKER": {
"active": True,
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}},
- }
+ },
},
"running": True,
},
@@ -52,18 +47,13 @@ DATA: list[dict[str, Any]] = [
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-TRACKER": {
"active": True,
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}},
- }
+ },
},
"running": True,
},
@@ -113,7 +103,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"trackers": [{"name": "FLOW-Sample"}]},
"expected": {
"result": "failure",
- "messages": ["Hardware flow tracker `FLOW-Sample` is not configured."],
+ "messages": ["Flow Tracker: FLOW-Sample - Not found"],
},
},
{
@@ -127,18 +117,13 @@ DATA: list[dict[str, Any]] = [
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-TRACKER": {
"active": False,
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}},
- }
+ },
},
"running": True,
},
@@ -159,7 +144,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Hardware flow tracker `FLOW-TRACKER` is not active.", "Hardware flow tracker `HARDWARE-TRACKER` is not active."],
+ "messages": ["Flow Tracker: FLOW-TRACKER - Disabled", "Flow Tracker: HARDWARE-TRACKER - Disabled"],
},
},
{
@@ -173,18 +158,13 @@ DATA: list[dict[str, Any]] = [
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-TRACKER": {
"active": True,
"inactiveTimeout": 6000,
"activeInterval": 30000,
"exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}},
- }
+ },
},
"running": True,
},
@@ -204,10 +184,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "FLOW-TRACKER: \n"
- "Expected `6000` as the inactive timeout, but found `60000` instead.\nExpected `30000` as the interval, but found `300000` instead.\n",
- "HARDWARE-TRACKER: \n"
- "Expected `60000` as the inactive timeout, but found `6000` instead.\nExpected `300000` as the interval, but found `30000` instead.\n",
+ "Flow Tracker: FLOW-TRACKER Inactive Timeout: 6000 Active Interval: 30000 - Incorrect timers - Inactive Timeout: 60000 OnActive Interval: 300000",
+ "Flow Tracker: HARDWARE-TRACKER Inactive Timeout: 60000 Active Interval: 300000 - Incorrect timers - "
+ "Inactive Timeout: 6000 OnActive Interval: 30000",
],
},
},
@@ -225,12 +204,7 @@ DATA: list[dict[str, Any]] = [
"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000},
"CVP-FLOW": {"localIntf": "Loopback0", "templateInterval": 3600000},
},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-TRACKER": {
"active": True,
"inactiveTimeout": 6000,
@@ -239,7 +213,7 @@ DATA: list[dict[str, Any]] = [
"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000},
"Hardware-flow": {"localIntf": "Loopback10", "templateInterval": 3600000},
},
- }
+ },
},
"running": True,
},
@@ -265,15 +239,11 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "FLOW-TRACKER: \n"
- "Exporter `CVP-FLOW`: \n"
- "Expected `Loopback10` as the local interface, but found `Loopback0` instead.\n"
- "Expected `3500000` as the template interval, but found `3600000` instead.\n",
- "HARDWARE-TRACKER: \n"
- "Exporter `Hardware-flow`: \n"
- "Expected `Loopback99` as the local interface, but found `Loopback10` instead.\n"
- "Expected `3000000` as the template interval, but found `3600000` instead.\n"
- "Exporter `Reverse-flow` is not configured.\n",
+ "Flow Tracker: FLOW-TRACKER Exporter: CVP-FLOW - Incorrect local interface - Expected: Loopback10 Actual: Loopback0",
+ "Flow Tracker: FLOW-TRACKER Exporter: CVP-FLOW - Incorrect template interval - Expected: 3500000 Actual: 3600000",
+ "Flow Tracker: HARDWARE-TRACKER Exporter: Hardware-flow - Incorrect local interface - Expected: Loopback99 Actual: Loopback10",
+ "Flow Tracker: HARDWARE-TRACKER Exporter: Hardware-flow - Incorrect template interval - Expected: 3000000 Actual: 3600000",
+ "Flow Tracker: HARDWARE-TRACKER Exporter: Reverse-flow - Not configured",
],
},
},
@@ -288,34 +258,19 @@ DATA: list[dict[str, Any]] = [
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"FLOW-TRIGGER": {
"active": False,
"inactiveTimeout": 60000,
"activeInterval": 300000,
"exporters": {"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-FLOW": {
"active": True,
"inactiveTimeout": 6000,
"activeInterval": 30000,
"exporters": {"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000}},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"FLOW-TRACKER2": {
"active": True,
"inactiveTimeout": 60000,
@@ -324,12 +279,7 @@ DATA: list[dict[str, Any]] = [
"CV-TELEMETRY": {"localIntf": "Loopback0", "templateInterval": 3600000},
"CVP-FLOW": {"localIntf": "Loopback0", "templateInterval": 3600000},
},
- }
- },
- "running": True,
- },
- {
- "trackers": {
+ },
"HARDWARE-TRACKER2": {
"active": True,
"inactiveTimeout": 6000,
@@ -338,7 +288,7 @@ DATA: list[dict[str, Any]] = [
"CVP-TELEMETRY": {"localIntf": "Loopback10", "templateInterval": 3600000},
"Hardware-flow": {"localIntf": "Loopback10", "templateInterval": 3600000},
},
- }
+ },
},
"running": True,
},
@@ -374,17 +324,14 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Hardware flow tracker `FLOW-Sample` is not configured.",
- "Hardware flow tracker `FLOW-TRIGGER` is not active.",
- "HARDWARE-FLOW: \n"
- "Expected `60000` as the inactive timeout, but found `6000` instead.\nExpected `300000` as the interval, but found `30000` instead.\n",
- "FLOW-TRACKER2: \nExporter `CVP-FLOW`: \n"
- "Expected `Loopback10` as the local interface, but found `Loopback0` instead.\n"
- "Expected `3500000` as the template interval, but found `3600000` instead.\n",
- "HARDWARE-TRACKER2: \nExporter `Hardware-flow`: \n"
- "Expected `Loopback99` as the local interface, but found `Loopback10` instead.\n"
- "Expected `3000000` as the template interval, but found `3600000` instead.\n"
- "Exporter `Reverse-flow` is not configured.\n",
+ "Flow Tracker: FLOW-Sample - Not found",
+ "Flow Tracker: FLOW-TRIGGER - Disabled",
+ "Flow Tracker: HARDWARE-FLOW Inactive Timeout: 60000 Active Interval: 300000 - Incorrect timers - Inactive Timeout: 6000 OnActive Interval: 30000",
+ "Flow Tracker: FLOW-TRACKER2 Exporter: CVP-FLOW - Incorrect local interface - Expected: Loopback10 Actual: Loopback0",
+ "Flow Tracker: FLOW-TRACKER2 Exporter: CVP-FLOW - Incorrect template interval - Expected: 3500000 Actual: 3600000",
+ "Flow Tracker: HARDWARE-TRACKER2 Exporter: Hardware-flow - Incorrect local interface - Expected: Loopback99 Actual: Loopback10",
+ "Flow Tracker: HARDWARE-TRACKER2 Exporter: Hardware-flow - Incorrect template interval - Expected: 3000000 Actual: 3600000",
+ "Flow Tracker: HARDWARE-TRACKER2 Exporter: Reverse-flow - Not configured",
],
},
},
diff --git a/tests/units/anta_tests/test_greent.py b/tests/units/anta_tests/test_greent.py
index 16f3616..3afb240 100644
--- a/tests/units/anta_tests/test_greent.py
+++ b/tests/units/anta_tests/test_greent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for testing anta.tests.configuration."""
diff --git a/tests/units/anta_tests/test_hardware.py b/tests/units/anta_tests/test_hardware.py
index 646ca58..972b07f 100644
--- a/tests/units/anta_tests/test_hardware.py
+++ b/tests/units/anta_tests/test_hardware.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.hardware."""
@@ -45,7 +45,13 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"manufacturers": ["Arista"]},
- "expected": {"result": "failure", "messages": ["Some transceivers are from unapproved manufacturers: {'1': 'Arista Networks', '2': 'Arista Networks'}"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Interface: 1 - Transceiver is from unapproved manufacturers - Expected: Arista Actual: Arista Networks",
+ "Interface: 2 - Transceiver is from unapproved manufacturers - Expected: Arista Actual: Arista Networks",
+ ],
+ },
},
{
"name": "success",
@@ -72,12 +78,12 @@ DATA: list[dict[str, Any]] = [
"ambientThreshold": 45,
"cardSlots": [],
"shutdownOnOverheat": "True",
- "systemStatus": "temperatureKO",
+ "systemStatus": "temperatureCritical",
"recoveryModeOnOverheat": "recoveryModeNA",
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Device temperature exceeds acceptable limits. Current system status: 'temperatureKO'"]},
+ "expected": {"result": "failure", "messages": ["Device temperature exceeds acceptable limits - Expected: temperatureOk Actual: temperatureCritical"]},
},
{
"name": "success",
@@ -139,11 +145,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [
- "The following sensors are operating outside the acceptable temperature range or have raised alerts: "
- "{'DomTemperatureSensor54': "
- "{'hwStatus': 'ko', 'alertCount': 0}}",
- ],
+ "messages": ["Sensor: DomTemperatureSensor54 - Invalid hardware state - Expected: ok Actual: ko"],
},
},
{
@@ -176,11 +178,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [
- "The following sensors are operating outside the acceptable temperature range or have raised alerts: "
- "{'DomTemperatureSensor54': "
- "{'hwStatus': 'ok', 'alertCount': 1}}",
- ],
+ "messages": ["Sensor: DomTemperatureSensor54 - Incorrect alert counter - Expected: 0 Actual: 1"],
},
},
{
@@ -227,7 +225,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Device system cooling is not OK: 'coolingKo'"]},
+ "expected": {"result": "failure", "messages": ["Device system cooling status invalid - Expected: coolingOk Actual: coolingKo"]},
},
{
"name": "success",
@@ -626,7 +624,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"states": ["ok", "Not Inserted"]},
- "expected": {"result": "failure", "messages": ["Fan 1/1 on Fan Tray 1 is: 'down'"]},
+ "expected": {"result": "failure", "messages": ["Fan Tray: 1 Fan: 1/1 - Invalid state - Expected: ok, Not Inserted Actual: down"]},
},
{
"name": "failure-power-supply",
@@ -759,7 +757,12 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"states": ["ok", "Not Inserted"]},
- "expected": {"result": "failure", "messages": ["Fan PowerSupply1/1 on PowerSupply PowerSupply1 is: 'down'"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Power Slot: PowerSupply1 Fan: PowerSupply1/1 - Invalid state - Expected: ok, Not Inserted Actual: down",
+ ],
+ },
},
{
"name": "success",
@@ -900,7 +903,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"states": ["ok"]},
- "expected": {"result": "failure", "messages": ["The following power supplies status are not in the accepted states list: {'1': {'state': 'powerLoss'}}"]},
+ "expected": {"result": "failure", "messages": ["Power Slot: 1 - Invalid power supplies state - Expected: ok Actual: powerLoss"]},
},
{
"name": "success",
@@ -914,6 +917,6 @@ DATA: list[dict[str, Any]] = [
"test": VerifyAdverseDrops,
"eos_data": [{"totalAdverseDrops": 10}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Device totalAdverseDrops counter is: '10'"]},
+ "expected": {"result": "failure", "messages": ["Incorrect total adverse drops counter - Expected: 0 Actual: 10"]},
},
]
diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py
index f3b4ee0..9f4ab11 100644
--- a/tests/units/anta_tests/test_interfaces.py
+++ b/tests/units/anta_tests/test_interfaces.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.interfaces."""
@@ -508,7 +508,10 @@ DATA: list[dict[str, Any]] = [
"inputs": {"threshold": 3.0},
"expected": {
"result": "failure",
- "messages": ["The following interfaces have a usage > 3.0%: {'Ethernet1/1': {'inBpsRate': 10.0}, 'Port-Channel31': {'outBpsRate': 5.0}}"],
+ "messages": [
+ "Interface: Ethernet1/1 BPS Rate: inBpsRate - Usage exceeds the threshold - Expected: < 3.0% Actual: 10.0%",
+ "Interface: Port-Channel31 BPS Rate: outBpsRate - Usage exceeds the threshold - Expected: < 3.0% Actual: 5.0%",
+ ],
},
},
{
@@ -653,7 +656,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"threshold": 70.0},
"expected": {
"result": "failure",
- "messages": ["Interface Ethernet1/1 or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented."],
+ "messages": ["Interface Ethernet1/1 or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented"],
},
},
{
@@ -787,7 +790,7 @@ DATA: list[dict[str, Any]] = [
},
"memberInterfaces": {
"Ethernet3/1": {"bandwidth": 1000000000, "duplex": "duplexHalf"},
- "Ethernet4/1": {"bandwidth": 1000000000, "duplex": "duplexFull"},
+ "Ethernet4/1": {"bandwidth": 1000000000, "duplex": "duplexHalf"},
},
"fallbackEnabled": False,
"fallbackEnabledType": "fallbackNone",
@@ -798,7 +801,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"threshold": 70.0},
"expected": {
"result": "failure",
- "messages": ["Interface Port-Channel31 or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented."],
+ "messages": ["Interface Port-Channel31 or one of its member interfaces is not Full-Duplex. VerifyInterfaceUtilization has not been implemented"],
},
},
{
@@ -830,9 +833,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts': 0,"
- " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}, {'Ethernet6': {'inErrors': 0, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts':"
- " 0, 'fcsErrors': 0, 'alignmentErrors': 666, 'symbolErrors': 0}}]",
+ "Interface: Ethernet1 - Non-zero error counter(s) - inErrors: 42",
+ "Interface: Ethernet6 - Non-zero error counter(s) - alignmentErrors: 666",
],
},
},
@@ -851,9 +853,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 10, 'frameTooShorts': 0,"
- " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}, {'Ethernet6': {'inErrors': 0, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts':"
- " 0, 'fcsErrors': 0, 'alignmentErrors': 6, 'symbolErrors': 10}}]",
+ "Interface: Ethernet1 - Non-zero error counter(s) - inErrors: 42, outErrors: 10",
+ "Interface: Ethernet6 - Non-zero error counter(s) - alignmentErrors: 6, symbolErrors: 10",
],
},
},
@@ -870,10 +871,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [
- "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 2, 'frameTooShorts': 0,"
- " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}]",
- ],
+ "messages": ["Interface: Ethernet1 - Non-zero error counter(s) - inErrors: 42, outErrors: 2"],
},
},
{
@@ -909,8 +907,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The following interfaces have non 0 discard counter(s): [{'Ethernet2': {'outDiscards': 42, 'inDiscards': 0}},"
- " {'Ethernet1': {'outDiscards': 0, 'inDiscards': 42}}]",
+ "Interface: Ethernet2 - Non-zero discard counter(s): outDiscards: 42",
+ "Interface: Ethernet1 - Non-zero discard counter(s): inDiscards: 42",
],
},
},
@@ -948,7 +946,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following interfaces are in error disabled state: ['Management1', 'Ethernet8']"]},
+ "expected": {"result": "failure", "messages": ["Interface: Management1 - Link status Error disabled", "Interface: Ethernet8 - Link status Error disabled"]},
},
{
"name": "success",
@@ -1126,7 +1124,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]},
"expected": {
"result": "failure",
- "messages": ["Ethernet8 - Expected: up/up, Actual: down/down"],
+ "messages": ["Ethernet8 - Status mismatch - Expected: up/up, Actual: down/down"],
},
},
{
@@ -1150,7 +1148,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Ethernet8 - Expected: up/up, Actual: up/down"],
+ "messages": ["Ethernet8 - Status mismatch - Expected: up/up, Actual: up/down"],
},
},
{
@@ -1166,7 +1164,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"interfaces": [{"name": "PortChannel100", "status": "up"}]},
"expected": {
"result": "failure",
- "messages": ["Port-Channel100 - Expected: up/up, Actual: down/lowerLayerDown"],
+ "messages": ["Port-Channel100 - Status mismatch - Expected: up/up, Actual: down/lowerLayerDown"],
},
},
{
@@ -1191,8 +1189,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Ethernet2 - Expected: up/down, Actual: up/unknown",
- "Ethernet8 - Expected: up/up, Actual: up/down",
+ "Ethernet2 - Status mismatch - Expected: up/down, Actual: up/unknown",
+ "Ethernet8 - Status mismatch - Expected: up/up, Actual: up/down",
],
},
},
@@ -1218,9 +1216,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Ethernet2 - Expected: down, Actual: up",
- "Ethernet8 - Expected: down, Actual: up",
- "Ethernet3 - Expected: down, Actual: up",
+ "Ethernet2 - Status mismatch - Expected: down, Actual: up",
+ "Ethernet8 - Status mismatch - Expected: down, Actual: up",
+ "Ethernet3 - Status mismatch - Expected: down, Actual: up",
],
},
},
@@ -1260,7 +1258,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following interfaces have none 0 storm-control drop counters {'Ethernet1': {'broadcast': 666}}"]},
+ "expected": {"result": "failure", "messages": ["Interface: Ethernet1 - Non-zero storm-control drop counter(s) - broadcast: 666"]},
},
{
"name": "success",
@@ -1306,7 +1304,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following port-channels have inactive port(s): ['Port-Channel42']"]},
+ "expected": {"result": "failure", "messages": ["Port-Channel42 - Inactive port(s) - Ethernet8"]},
},
{
"name": "success",
@@ -1362,7 +1360,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["The following port-channels have received illegal LACP packets on the following ports: [{'Port-Channel42': 'Ethernet8'}]"],
+ "messages": ["Port-Channel42 Interface: Ethernet8 - Illegal LACP packets found"],
},
},
{
@@ -1417,7 +1415,7 @@ DATA: list[dict[str, Any]] = [
},
"Loopback666": {
"name": "Loopback666",
- "interfaceStatus": "connected",
+ "interfaceStatus": "notconnect",
"interfaceAddress": {"ipAddr": {"maskLen": 32, "address": "6.6.6.6"}},
"ipv4Routable240": False,
"lineProtocolStatus": "down",
@@ -1427,7 +1425,13 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"number": 2},
- "expected": {"result": "failure", "messages": ["The following Loopbacks are not up: ['Loopback666']"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Interface: Loopback666 - Invalid line protocol status - Expected: up Actual: down",
+ "Interface: Loopback666 - Invalid interface status - Expected: connected Actual: notconnect",
+ ],
+ },
},
{
"name": "failure-count-loopback",
@@ -1447,7 +1451,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"number": 2},
- "expected": {"result": "failure", "messages": ["Found 1 Loopbacks when expecting 2"]},
+ "expected": {"result": "failure", "messages": ["Loopback interface(s) count mismatch: Expected 2 Actual: 1"]},
},
{
"name": "success",
@@ -1487,7 +1491,13 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following SVIs are not up: ['Vlan42']"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "SVI: Vlan42 - Invalid line protocol status - Expected: up Actual: lowerLayerDown",
+ "SVI: Vlan42 - Invalid interface status - Expected: connected Actual: notconnect",
+ ],
+ },
},
{
"name": "success",
@@ -1703,7 +1713,79 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"mtu": 1500},
- "expected": {"result": "failure", "messages": ["Some interfaces do not have correct MTU configured:\n[{'Ethernet2': 1600}]"]},
+ "expected": {"result": "failure", "messages": ["Interface: Ethernet2 - Incorrect MTU - Expected: 1500 Actual: 1600"]},
+ },
+ {
+ "name": "failure-specified-interface-mtu",
+ "test": VerifyL3MTU,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "name": "Ethernet2",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1500,
+ "l3MtuConfigured": True,
+ "l2Mru": 0,
+ },
+ "Ethernet10": {
+ "name": "Ethernet10",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1502,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Management0": {
+ "name": "Management0",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1500,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Port-Channel2": {
+ "name": "Port-Channel2",
+ "forwardingModel": "bridged",
+ "lineProtocolStatus": "lowerLayerDown",
+ "interfaceStatus": "notconnect",
+ "hardware": "portChannel",
+ "mtu": 1500,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Loopback0": {
+ "name": "Loopback0",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "loopback",
+ "mtu": 65535,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Vxlan1": {
+ "name": "Vxlan1",
+ "forwardingModel": "bridged",
+ "lineProtocolStatus": "down",
+ "interfaceStatus": "notconnect",
+ "hardware": "vxlan",
+ "mtu": 0,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ },
+ },
+ ],
+ "inputs": {"mtu": 1500, "ignored_interfaces": ["Loopback", "Port-Channel", "Management", "Vxlan"], "specific_mtu": [{"Ethernet10": 1501}]},
+ "expected": {"result": "failure", "messages": ["Interface: Ethernet10 - Incorrect MTU - Expected: 1501 Actual: 1502"]},
},
{
"name": "success",
@@ -1847,7 +1929,85 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"mtu": 1500},
- "expected": {"result": "failure", "messages": ["Some L2 interfaces do not have correct MTU configured:\n[{'Ethernet10': 9214}, {'Port-Channel2': 9214}]"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Interface: Ethernet10 - Incorrect MTU configured - Expected: 1500 Actual: 9214",
+ "Interface: Port-Channel2 - Incorrect MTU configured - Expected: 1500 Actual: 9214",
+ ],
+ },
+ },
+ {
+ "name": "failure-specific-interface",
+ "test": VerifyL2MTU,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "name": "Ethernet2",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1600,
+ "l3MtuConfigured": True,
+ "l2Mru": 0,
+ },
+ "Ethernet10": {
+ "name": "Ethernet10",
+ "forwardingModel": "bridged",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 9214,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Management0": {
+ "name": "Management0",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1500,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Port-Channel2": {
+ "name": "Port-Channel2",
+ "forwardingModel": "bridged",
+ "lineProtocolStatus": "lowerLayerDown",
+ "interfaceStatus": "notconnect",
+ "hardware": "portChannel",
+ "mtu": 9214,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Loopback0": {
+ "name": "Loopback0",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "loopback",
+ "mtu": 65535,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Vxlan1": {
+ "name": "Vxlan1",
+ "forwardingModel": "bridged",
+ "lineProtocolStatus": "down",
+ "interfaceStatus": "notconnect",
+ "hardware": "vxlan",
+ "mtu": 0,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ },
+ },
+ ],
+ "inputs": {"specific_mtu": [{"Et10": 9214}, {"Port-Channel2": 10000}]},
+ "expected": {"result": "failure", "messages": ["Interface: Port-Channel2 - Incorrect MTU configured - Expected: 10000 Actual: 9214"]},
},
{
"name": "success",
@@ -1859,45 +2019,13 @@ DATA: list[dict[str, Any]] = [
"name": "Ethernet1",
"lineProtocolStatus": "up",
"interfaceStatus": "connected",
- "mtu": 1500,
- "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.0", "maskLen": 31}},
- "ipv4Routable240": False,
- "ipv4Routable0": False,
- "enabled": True,
- "description": "P2P_LINK_TO_NW-CORE_Ethernet1",
"proxyArp": True,
- "localProxyArp": False,
- "gratuitousArp": False,
- "vrf": "default",
- "urpf": "disable",
- "addresslessForwarding": "isInvalid",
- "directedBroadcastEnabled": False,
- "maxMssIngress": 0,
- "maxMssEgress": 0,
},
- },
- },
- {
- "interfaces": {
"Ethernet2": {
"name": "Ethernet2",
"lineProtocolStatus": "up",
"interfaceStatus": "connected",
- "mtu": 1500,
- "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.2", "maskLen": 31}},
- "ipv4Routable240": False,
- "ipv4Routable0": False,
- "enabled": True,
- "description": "P2P_LINK_TO_SW-CORE_Ethernet1",
"proxyArp": True,
- "localProxyArp": False,
- "gratuitousArp": False,
- "vrf": "default",
- "urpf": "disable",
- "addresslessForwarding": "isInvalid",
- "directedBroadcastEnabled": False,
- "maxMssIngress": 0,
- "maxMssEgress": 0,
},
},
},
@@ -1905,6 +2033,24 @@ DATA: list[dict[str, Any]] = [
"inputs": {"interfaces": ["Ethernet1", "Ethernet2"]},
"expected": {"result": "success"},
},
+ {
+ "name": "failure-interface-not-found",
+ "test": VerifyIPProxyARP,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet1": {
+ "name": "Ethernet1",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "proxyArp": True,
+ },
+ },
+ },
+ ],
+ "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]},
+ "expected": {"result": "failure", "messages": ["Interface: Ethernet2 - Not found"]},
+ },
{
"name": "failure",
"test": VerifyIPProxyARP,
@@ -1915,51 +2061,19 @@ DATA: list[dict[str, Any]] = [
"name": "Ethernet1",
"lineProtocolStatus": "up",
"interfaceStatus": "connected",
- "mtu": 1500,
- "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.0", "maskLen": 31}},
- "ipv4Routable240": False,
- "ipv4Routable0": False,
- "enabled": True,
- "description": "P2P_LINK_TO_NW-CORE_Ethernet1",
"proxyArp": True,
- "localProxyArp": False,
- "gratuitousArp": False,
- "vrf": "default",
- "urpf": "disable",
- "addresslessForwarding": "isInvalid",
- "directedBroadcastEnabled": False,
- "maxMssIngress": 0,
- "maxMssEgress": 0,
},
- },
- },
- {
- "interfaces": {
"Ethernet2": {
"name": "Ethernet2",
"lineProtocolStatus": "up",
"interfaceStatus": "connected",
- "mtu": 1500,
- "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.2", "maskLen": 31}},
- "ipv4Routable240": False,
- "ipv4Routable0": False,
- "enabled": True,
- "description": "P2P_LINK_TO_SW-CORE_Ethernet1",
"proxyArp": False,
- "localProxyArp": False,
- "gratuitousArp": False,
- "vrf": "default",
- "urpf": "disable",
- "addresslessForwarding": "isInvalid",
- "directedBroadcastEnabled": False,
- "maxMssIngress": 0,
- "maxMssEgress": 0,
},
},
},
],
"inputs": {"interfaces": ["Ethernet1", "Ethernet2"]},
- "expected": {"result": "failure", "messages": ["The following interface(s) have Proxy-ARP disabled: ['Ethernet2']"]},
+ "expected": {"result": "failure", "messages": ["Interface: Ethernet2 - Proxy-ARP disabled"]},
},
{
"name": "success",
@@ -1972,17 +2086,13 @@ DATA: list[dict[str, Any]] = [
"primaryIp": {"address": "172.30.11.1", "maskLen": 31},
"secondaryIpsOrderedList": [{"address": "10.10.10.1", "maskLen": 31}, {"address": "10.10.10.10", "maskLen": 31}],
}
- }
- }
- },
- {
- "interfaces": {
+ },
"Ethernet12": {
"interfaceAddress": {
"primaryIp": {"address": "172.30.11.10", "maskLen": 31},
"secondaryIpsOrderedList": [{"address": "10.10.10.10", "maskLen": 31}, {"address": "10.10.10.20", "maskLen": 31}],
}
- }
+ },
}
},
],
@@ -2005,17 +2115,13 @@ DATA: list[dict[str, Any]] = [
"primaryIp": {"address": "172.30.11.0", "maskLen": 31},
"secondaryIpsOrderedList": [],
}
- }
- }
- },
- {
- "interfaces": {
+ },
"Ethernet12": {
"interfaceAddress": {
"primaryIp": {"address": "172.30.11.10", "maskLen": 31},
"secondaryIpsOrderedList": [],
}
- }
+ },
}
},
],
@@ -2028,9 +2134,20 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "failure-not-l3-interface",
+ "name": "failure-interface-not-found",
"test": VerifyInterfaceIPv4,
- "eos_data": [{"interfaces": {"Ethernet2": {"interfaceAddress": {}}}}, {"interfaces": {"Ethernet12": {"interfaceAddress": {}}}}],
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet10": {
+ "interfaceAddress": {
+ "primaryIp": {"address": "172.30.11.0", "maskLen": 31},
+ "secondaryIpsOrderedList": [],
+ }
+ }
+ }
+ }
+ ],
"inputs": {
"interfaces": [
{"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]},
@@ -2039,7 +2156,22 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["For interface `Ethernet2`, IP address is not configured.", "For interface `Ethernet12`, IP address is not configured."],
+ "messages": ["Interface: Ethernet2 - Not found", "Interface: Ethernet12 - Not found"],
+ },
+ },
+ {
+ "name": "failure-not-l3-interface",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [{"interfaces": {"Ethernet2": {"interfaceAddress": {}}, "Ethernet12": {"interfaceAddress": {}}}}],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]},
+ {"name": "Ethernet12", "primary_ip": "172.30.11.20/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["Interface: Ethernet2 - IP address is not configured", "Interface: Ethernet12 - IP address is not configured"],
},
},
{
@@ -2053,17 +2185,13 @@ DATA: list[dict[str, Any]] = [
"primaryIp": {"address": "0.0.0.0", "maskLen": 0},
"secondaryIpsOrderedList": [],
}
- }
- }
- },
- {
- "interfaces": {
+ },
"Ethernet12": {
"interfaceAddress": {
"primaryIp": {"address": "0.0.0.0", "maskLen": 0},
"secondaryIpsOrderedList": [],
}
- }
+ },
}
},
],
@@ -2076,10 +2204,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "For interface `Ethernet2`, The expected primary IP address is `172.30.11.0/31`, but the actual primary IP address is `0.0.0.0/0`. "
- "The expected secondary IP addresses are `['10.10.10.0/31', '10.10.10.10/31']`, but the actual secondary IP address is not configured.",
- "For interface `Ethernet12`, The expected primary IP address is `172.30.11.10/31`, but the actual primary IP address is `0.0.0.0/0`. "
- "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP address is not configured.",
+ "Interface: Ethernet2 - IP address mismatch - Expected: 172.30.11.0/31 Actual: 0.0.0.0/0",
+ "Interface: Ethernet2 - Secondary IP address is not configured",
+ "Interface: Ethernet12 - IP address mismatch - Expected: 172.30.11.10/31 Actual: 0.0.0.0/0",
+ "Interface: Ethernet12 - Secondary IP address is not configured",
],
},
},
@@ -2094,17 +2222,13 @@ DATA: list[dict[str, Any]] = [
"primaryIp": {"address": "172.30.11.0", "maskLen": 31},
"secondaryIpsOrderedList": [{"address": "10.10.10.0", "maskLen": 31}, {"address": "10.10.10.10", "maskLen": 31}],
}
- }
- }
- },
- {
- "interfaces": {
+ },
"Ethernet3": {
"interfaceAddress": {
"primaryIp": {"address": "172.30.10.10", "maskLen": 31},
"secondaryIpsOrderedList": [{"address": "10.10.11.0", "maskLen": 31}, {"address": "10.11.11.10", "maskLen": 31}],
}
- }
+ },
}
},
],
@@ -2117,12 +2241,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "For interface `Ethernet2`, The expected primary IP address is `172.30.11.2/31`, but the actual primary IP address is `172.30.11.0/31`. "
- "The expected secondary IP addresses are `['10.10.10.20/31', '10.10.10.30/31']`, but the actual secondary IP addresses are "
- "`['10.10.10.0/31', '10.10.10.10/31']`.",
- "For interface `Ethernet3`, The expected primary IP address is `172.30.10.2/31`, but the actual primary IP address is `172.30.10.10/31`. "
- "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP addresses are "
- "`['10.10.11.0/31', '10.11.11.10/31']`.",
+ "Interface: Ethernet2 - IP address mismatch - Expected: 172.30.11.2/31 Actual: 172.30.11.0/31",
+ "Interface: Ethernet2 - Secondary IP address mismatch - Expected: 10.10.10.20/31, 10.10.10.30/31 Actual: 10.10.10.0/31, 10.10.10.10/31",
+ "Interface: Ethernet3 - IP address mismatch - Expected: 172.30.10.2/31 Actual: 172.30.10.10/31",
+ "Interface: Ethernet3 - Secondary IP address mismatch - Expected: 10.10.11.0/31, 10.10.11.10/31 Actual: 10.10.11.0/31, 10.11.11.10/31",
],
},
},
@@ -2137,17 +2259,13 @@ DATA: list[dict[str, Any]] = [
"primaryIp": {"address": "172.30.11.0", "maskLen": 31},
"secondaryIpsOrderedList": [],
}
- }
- }
- },
- {
- "interfaces": {
+ },
"Ethernet3": {
"interfaceAddress": {
"primaryIp": {"address": "172.30.10.10", "maskLen": 31},
"secondaryIpsOrderedList": [{"address": "10.10.11.0", "maskLen": 31}, {"address": "10.11.11.10", "maskLen": 31}],
}
- }
+ },
}
},
],
@@ -2160,11 +2278,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "For interface `Ethernet2`, The expected primary IP address is `172.30.11.2/31`, but the actual primary IP address is `172.30.11.0/31`. "
- "The expected secondary IP addresses are `['10.10.10.20/31', '10.10.10.30/31']`, but the actual secondary IP address is not configured.",
- "For interface `Ethernet3`, The expected primary IP address is `172.30.10.2/31`, but the actual primary IP address is `172.30.10.10/31`. "
- "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP addresses are "
- "`['10.10.11.0/31', '10.11.11.10/31']`.",
+ "Interface: Ethernet2 - IP address mismatch - Expected: 172.30.11.2/31 Actual: 172.30.11.0/31",
+ "Interface: Ethernet2 - Secondary IP address is not configured",
+ "Interface: Ethernet3 - IP address mismatch - Expected: 172.30.10.2/31 Actual: 172.30.10.10/31",
+ "Interface: Ethernet3 - Secondary IP address mismatch - Expected: 10.10.11.0/31, 10.10.11.10/31 Actual: 10.10.11.0/31, 10.11.11.10/31",
],
},
},
@@ -2196,7 +2313,7 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {"mac_address": "00:1c:73:00:dc:01"},
- "expected": {"result": "failure", "messages": ["IP virtual router MAC address `00:1c:73:00:dc:01` is not configured."]},
+ "expected": {"result": "failure", "messages": ["IP virtual router MAC address: 00:1c:73:00:dc:01 - Not configured"]},
},
{
"name": "success",
@@ -2288,10 +2405,10 @@ DATA: list[dict[str, Any]] = [
"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.",
+ "Interface: Ethernet1 - Bandwidth mismatch - Expected: 1.0Gbps Actual: 100Gbps",
+ "Interface: Ethernet1/1/1 - Bandwidth mismatch - Expected: 1.0Gbps Actual: 100Gbps",
+ "Interface: Ethernet3 - Bandwidth mismatch - Expected: 100.0Gbps Actual: 10Gbps",
+ "Interface: Ethernet4 - Bandwidth mismatch - Expected: 2.5Gbps Actual: 25Gbps",
],
},
},
@@ -2340,11 +2457,11 @@ DATA: list[dict[str, Any]] = [
"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.",
+ "Interface: Ethernet1 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet1/2/2 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet3 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet3 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet4 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
],
},
},
@@ -2398,10 +2515,10 @@ DATA: list[dict[str, Any]] = [
"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.",
+ "Interface: Ethernet1 - Data lanes count mismatch - Expected: 2 Actual: 4",
+ "Interface: Ethernet3 - Data lanes count mismatch - Expected: 8 Actual: 4",
+ "Interface: Ethernet4 - Data lanes count mismatch - Expected: 4 Actual: 6",
+ "Interface: Ethernet4/1/1 - Data lanes count mismatch - Expected: 4 Actual: 6",
],
},
},
@@ -2440,36 +2557,26 @@ DATA: list[dict[str, Any]] = [
],
"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.",
+ "Interface: Ethernet1 - Bandwidth mismatch - Expected: 1.0Gbps Actual: 10Gbps",
+ "Interface: Ethernet1 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet1 - Data lanes count mismatch - Expected: 2 Actual: 4",
+ "Interface: Ethernet2/1/2 - Bandwidth mismatch - Expected: 10.0Gbps Actual: 1Gbps",
+ "Interface: Ethernet2/1/2 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet3 - Bandwidth mismatch - Expected: 100.0Gbps Actual: 10Gbps",
+ "Interface: Ethernet3 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
+ "Interface: Ethernet3 - Auto-negotiation mismatch - Expected: success Actual: unknown",
+ "Interface: Ethernet3 - Data lanes count mismatch - Expected: 8 Actual: 6",
+ "Interface: Ethernet4 - Bandwidth mismatch - Expected: 2.5Gbps Actual: 25Gbps",
+ "Interface: Ethernet4 - Duplex mode mismatch - Expected: duplexFull Actual: duplexHalf",
],
},
},
diff --git a/tests/units/anta_tests/test_lanz.py b/tests/units/anta_tests/test_lanz.py
index 03694d4..99a5771 100644
--- a/tests/units/anta_tests/test_lanz.py
+++ b/tests/units/anta_tests/test_lanz.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for testing anta.tests.lanz."""
diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py
index b429436..5f79589 100644
--- a/tests/units/anta_tests/test_logging.py
+++ b/tests/units/anta_tests/test_logging.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for testing anta.tests.logging."""
@@ -9,6 +9,7 @@ from typing import Any
from anta.tests.logging import (
VerifyLoggingAccounting,
+ VerifyLoggingEntries,
VerifyLoggingErrors,
VerifyLoggingHostname,
VerifyLoggingHosts,
@@ -16,6 +17,7 @@ from anta.tests.logging import (
VerifyLoggingPersistent,
VerifyLoggingSourceIntf,
VerifyLoggingTimestamp,
+ VerifySyslogLogging,
)
from tests.units.anta_tests import test
@@ -96,7 +98,7 @@ DATA: list[dict[str, Any]] = [
""",
],
"inputs": {"interface": "Management0", "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["Source-interface: Management0 VRF: MGMT - Not configured"]},
},
{
"name": "failure-vrf",
@@ -111,7 +113,7 @@ DATA: list[dict[str, Any]] = [
""",
],
"inputs": {"interface": "Management0", "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["Source-interface: Management0 VRF: MGMT - Not configured"]},
},
{
"name": "success",
@@ -141,7 +143,7 @@ DATA: list[dict[str, Any]] = [
""",
],
"inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Syslog servers ['10.22.10.93', '10.22.10.94'] are not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["Syslog servers 10.22.10.93, 10.22.10.94 are not configured in VRF MGMT"]},
},
{
"name": "failure-vrf",
@@ -156,7 +158,7 @@ DATA: list[dict[str, Any]] = [
""",
],
"inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Syslog servers ['10.22.10.93', '10.22.10.94'] are not configured in VRF MGMT"]},
+ "expected": {"result": "failure", "messages": ["Syslog servers 10.22.10.93, 10.22.10.94 are not configured in VRF MGMT"]},
},
{
"name": "success",
@@ -166,14 +168,14 @@ DATA: list[dict[str, Any]] = [
"2023-05-10T13:54:21.463497-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
"Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingLogsGeneration validation\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "informational"},
"expected": {"result": "success"},
},
{
"name": "failure",
"test": VerifyLoggingLogsGeneration,
"eos_data": ["", "Log Buffer:\n"],
- "inputs": None,
+ "inputs": {"severity_level": "notifications"},
"expected": {"result": "failure", "messages": ["Logs are not generated"]},
},
{
@@ -185,7 +187,7 @@ DATA: list[dict[str, Any]] = [
"2023-05-10T15:41:44.701810-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
"Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingHostname validation\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "informational"},
"expected": {"result": "success"},
},
{
@@ -194,10 +196,10 @@ DATA: list[dict[str, Any]] = [
"eos_data": [
{"hostname": "NW-CORE", "fqdn": "NW-CORE.example.org"},
"",
- "2023-05-10T13:54:21.463497-05:00 NW-CORE ConfigAgent: %SYS-6-LOGMSG_INFO: "
+ "2023-05-10T13:54:21.463497-05:00 NW-CORE ConfigAgent: %SYS-6-LOGMSG_NOTICE: "
"Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingLogsHostname validation\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "notifications"},
"expected": {"result": "failure", "messages": ["Logs are not generated with the device FQDN"]},
},
{
@@ -210,7 +212,7 @@ DATA: list[dict[str, Any]] = [
"2023-05-10T15:42:44.680813-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
"Other log\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "informational"},
"expected": {"result": "success"},
},
{
@@ -223,7 +225,7 @@ DATA: list[dict[str, Any]] = [
"2023-05-10T15:42:44.680813+05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
"Other log\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "informational"},
"expected": {"result": "success"},
},
{
@@ -231,10 +233,10 @@ DATA: list[dict[str, Any]] = [
"test": VerifyLoggingTimestamp,
"eos_data": [
"",
- "May 10 13:54:22 NE-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
+ "May 10 13:54:22 NE-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_ALERT: "
"Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "alerts"},
"expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]},
},
{
@@ -242,9 +244,9 @@ DATA: list[dict[str, Any]] = [
"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",
+ "May 10 13:54:22 NE-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_NOTICE: Message from arista on command-api (10.22.1.107): BLAH\n",
],
- "inputs": None,
+ "inputs": {"severity_level": "notifications"},
"expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]},
},
{
@@ -277,4 +279,85 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {"result": "failure", "messages": ["Device has reported syslog messages with a severity of ERRORS or higher"]},
},
+ {
+ "name": "success",
+ "test": VerifySyslogLogging,
+ "eos_data": [
+ """Syslog logging: enabled
+ Buffer logging: level debugging
+
+ External configuration:
+ active:
+ inactive:
+
+ Facility Severity Effective Severity
+ -------------------- ------------- ------------------
+ aaa debugging debugging
+ accounting debugging debugging""",
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifySyslogLogging,
+ "eos_data": [
+ """Syslog logging: disabled
+ Buffer logging: level debugging
+ Console logging: level errors
+ Persistent logging: disabled
+ Monitor logging: level errors
+
+ External configuration:
+ active:
+ inactive:
+
+ Facility Severity Effective Severity
+ -------------------- ------------- ------------------
+ aaa debugging debugging
+ accounting debugging debugging""",
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Syslog logging is disabled"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingEntries,
+ "eos_data": [
+ """Mar 13 04:10:45 s1-leaf1 ProcMgr: %PROCMGR-6-TERMINATE_RUNNING_PROCESS: Terminating deconfigured/reconfigured process 'SystemInitMonitor' (PID=859)
+ Mar 13 04:10:45 s1-leaf1 ProcMgr: %PROCMGR-6-PROCESS_TERMINATED: 'SystemInitMonitor' (PID=859, status=9) has terminated.""",
+ """Mar 13 04:10:45 s1-leaf1 ProcMgr: %PROCMGR-7-WORKER_WARMSTART_DONE: ProcMgr worker warm start done. (PID=547)""",
+ ],
+ "inputs": {
+ "logging_entries": [
+ {"regex_match": ".*PROCMGR-6-PROCESS_TERMINATED:.*", "last_number_messages": 3},
+ {"regex_match": ".*ProcMgr worker warm start.*", "last_number_messages": 2, "severity_level": "debugging"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-log-str-not-found",
+ "test": VerifyLoggingEntries,
+ "eos_data": [
+ """Mar 12 04:34:01 s1-leaf1 ProcMgr: %PROCMGR-7-WORKER_WARMSTART_DONE: ProcMgr worker warm start done. (PID=559)
+Mar 12 04:34:01 s1-leaf1 ProcMgr: %PROCMGR-6-PROCESS_TERMINATED: 'SystemInitMonitor' (PID=867, status=9) has terminated.""",
+ """Mar 13 03:58:12 s1-leaf1 ConfigAgent: %SYS-5-CONFIG_SESSION_ABORTED: User cvpsystem aborted
+ configuration session capiVerify-612-612b34a2ffbf11ef96ba3a348d538ba0 on TerminAttr (localhost)
+ Mar 13 04:10:45 s1-leaf1 SystemInitMonitor: %SYS-5-SYSTEM_INITIALIZED: System is initialized""",
+ ],
+ "inputs": {
+ "logging_entries": [
+ {"regex_match": ".ACCOUNTING-5-EXEC: cvpadmin ssh.", "last_number_messages": 3},
+ {"regex_match": ".*ProcMgr worker warm start.*", "last_number_messages": 10, "severity_level": "debugging"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Pattern: .ACCOUNTING-5-EXEC: cvpadmin ssh. - Not found in last 3 informational log entries",
+ "Pattern: .*ProcMgr worker warm start.* - Not found in last 10 debugging log entries",
+ ],
+ },
+ },
]
diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py
index 193d69c..c49e0f7 100644
--- a/tests/units/anta_tests/test_mlag.py
+++ b/tests/units/anta_tests/test_mlag.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.mlag.py."""
@@ -30,13 +30,33 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
},
{
- "name": "failure",
+ "name": "failure-negotiation-status",
+ "test": VerifyMlagStatus,
+ "eos_data": [{"state": "active", "negStatus": "connecting", "peerLinkStatus": "up", "localIntfStatus": "up"}],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": ["MLAG negotiation status mismatch - Expected: connected Actual: connecting"],
+ },
+ },
+ {
+ "name": "failure-local-interface",
+ "test": VerifyMlagStatus,
+ "eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "up", "localIntfStatus": "down"}],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": ["Operational state of the MLAG local interface is not correct - Expected: up Actual: down"],
+ },
+ },
+ {
+ "name": "failure-peer-link",
"test": VerifyMlagStatus,
"eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "down", "localIntfStatus": "up"}],
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["MLAG status is not OK: {'state': 'active', 'negStatus': 'connected', 'localIntfStatus': 'up', 'peerLinkStatus': 'down'}"],
+ "messages": ["Operational state of the MLAG peer link is not correct - Expected: up Actual: down"],
},
},
{
@@ -74,7 +94,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 0, 'Active-partial': 1, 'Active-full': 1}"],
+ "messages": ["MLAG status is not ok - Inactive Ports: 0 Partial Active Ports: 1"],
},
},
{
@@ -89,7 +109,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 1, 'Active-partial': 1, 'Active-full': 1}"],
+ "messages": ["MLAG status is not ok - Inactive Ports: 1 Partial Active Ports: 1"],
},
},
{
@@ -124,12 +144,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [
- "MLAG config-sanity returned inconsistencies: "
- "{'globalConfiguration': {'mlag': {'globalParameters': "
- "{'dual-primary-detection-delay': {'localValue': '0', 'peerValue': '200'}}}}, "
- "'interfaceConfiguration': {}}",
- ],
+ "messages": ["MLAG config-sanity found in global configuration"],
},
},
{
@@ -146,12 +161,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [
- "MLAG config-sanity returned inconsistencies: "
- "{'globalConfiguration': {}, "
- "'interfaceConfiguration': {'trunk-native-vlan mlag30': "
- "{'interface': {'Port-Channel30': {'localValue': '123', 'peerValue': '3700'}}}}}",
- ],
+ "messages": ["MLAG config-sanity found in interface configuration"],
},
},
{
@@ -177,7 +187,10 @@ DATA: list[dict[str, Any]] = [
"test": VerifyMlagReloadDelay,
"eos_data": [{"state": "active", "reloadDelay": 400, "reloadDelayNonMlag": 430}],
"inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330},
- "expected": {"result": "failure", "messages": ["The reload-delay parameters are not configured properly: {'reloadDelay': 400, 'reloadDelayNonMlag': 430}"]},
+ "expected": {
+ "result": "failure",
+ "messages": ["MLAG reload-delay mismatch - Expected: 300s Actual: 400s", "Delay for non-MLAG ports mismatch - Expected: 330s Actual: 430s"],
+ },
},
{
"name": "success",
@@ -236,13 +249,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- (
- "The dual-primary parameters are not configured properly: "
- "{'detail.dualPrimaryDetectionDelay': 300, "
- "'detail.dualPrimaryAction': 'none', "
- "'dualPrimaryMlagRecoveryDelay': 160, "
- "'dualPrimaryNonMlagRecoveryDelay': 0}"
- ),
+ "Dual-primary detection delay mismatch - Expected: 200 Actual: 300",
+ "Dual-primary MLAG recovery delay mismatch - Expected: 60 Actual: 160",
],
},
},
@@ -262,15 +270,26 @@ DATA: list[dict[str, Any]] = [
"inputs": {"detection_delay": 200, "errdisabled": True, "recovery_delay": 60, "recovery_delay_non_mlag": 0},
"expected": {
"result": "failure",
- "messages": [
- (
- "The dual-primary parameters are not configured properly: "
- "{'detail.dualPrimaryDetectionDelay': 200, "
- "'detail.dualPrimaryAction': 'none', "
- "'dualPrimaryMlagRecoveryDelay': 60, "
- "'dualPrimaryNonMlagRecoveryDelay': 0}"
- ),
- ],
+ "messages": ["Dual-primary action mismatch - Expected: errdisableAllInterfaces Actual: none"],
+ },
+ },
+ {
+ "name": "failure-wrong-non-mlag-delay",
+ "test": VerifyMlagDualPrimary,
+ "eos_data": [
+ {
+ "state": "active",
+ "dualPrimaryDetectionState": "configured",
+ "dualPrimaryPortsErrdisabled": False,
+ "dualPrimaryMlagRecoveryDelay": 60,
+ "dualPrimaryNonMlagRecoveryDelay": 120,
+ "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "errdisableAllInterfaces"},
+ },
+ ],
+ "inputs": {"detection_delay": 200, "errdisabled": True, "recovery_delay": 60, "recovery_delay_non_mlag": 60},
+ "expected": {
+ "result": "failure",
+ "messages": ["Dual-primary non MLAG recovery delay mismatch - Expected: 60 Actual: 120"],
},
},
{
@@ -310,7 +329,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"primary_priority": 32767},
"expected": {
"result": "failure",
- "messages": ["The device is not set as MLAG primary."],
+ "messages": ["The device is not set as MLAG primary"],
},
},
{
@@ -325,7 +344,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"primary_priority": 1},
"expected": {
"result": "failure",
- "messages": ["The device is not set as MLAG primary.", "The primary priority does not match expected. Expected `1`, but found `32767` instead."],
+ "messages": ["The device is not set as MLAG primary", "MLAG primary priority mismatch - Expected: 1 Actual: 32767"],
},
},
]
diff --git a/tests/units/anta_tests/test_multicast.py b/tests/units/anta_tests/test_multicast.py
index 1fdcadd..7e33e26 100644
--- a/tests/units/anta_tests/test_multicast.py
+++ b/tests/units/anta_tests/test_multicast.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.multicast."""
@@ -104,7 +104,10 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"vlans": {1: False, 42: False}},
- "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is enabled", "Supplied vlan 42 is not present on the device."]},
+ "expected": {
+ "result": "failure",
+ "messages": ["VLAN1 - Incorrect IGMP state - Expected: disabled Actual: enabled", "Supplied vlan 42 is not present on the device"],
+ },
},
{
"name": "failure-wrong-state",
@@ -132,7 +135,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"vlans": {1: True}},
- "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is disabled"]},
+ "expected": {"result": "failure", "messages": ["VLAN1 - Incorrect IGMP state - Expected: enabled Actual: disabled"]},
},
{
"name": "success-enabled",
@@ -171,6 +174,6 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"enabled": True},
- "expected": {"result": "failure", "messages": ["IGMP state is not valid: disabled"]},
+ "expected": {"result": "failure", "messages": ["IGMP state is not valid - Expected: enabled Actual: disabled"]},
},
]
diff --git a/tests/units/anta_tests/test_path_selection.py b/tests/units/anta_tests/test_path_selection.py
index d1882d0..45b0e24 100644
--- a/tests/units/anta_tests/test_path_selection.py
+++ b/tests/units/anta_tests/test_path_selection.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
@@ -58,7 +58,7 @@ DATA: list[dict[str, Any]] = [
{"dpsPeers": {}},
],
"inputs": {},
- "expected": {"result": "failure", "messages": ["No path configured for router path-selection."]},
+ "expected": {"result": "failure", "messages": ["No path configured for router path-selection"]},
},
{
"name": "failure-not-established",
@@ -101,9 +101,9 @@ DATA: list[dict[str, Any]] = [
"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`.",
+ "Peer: 10.255.0.1 Path Group: internet - Invalid path state - Expected: ipsecEstablished, routeResolved Actual: ipsecPending",
+ "Peer: 10.255.0.1 Path Group: mpls - Invalid path state - Expected: ipsecEstablished, routeResolved Actual: ipsecPending",
+ "Peer: 10.255.0.2 Path Group: mpls - Invalid path state - Expected: ipsecEstablished, routeResolved Actual: ipsecPending",
],
},
},
@@ -148,9 +148,9 @@ DATA: list[dict[str, Any]] = [
"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`.",
+ "Peer: 10.255.0.1 Path Group internet - Telemetry state inactive",
+ "Peer: 10.255.0.1 Path Group mpls - Telemetry state inactive",
+ "Peer: 10.255.0.2 Path Group mpls - Telemetry state inactive",
],
},
},
@@ -158,107 +158,125 @@ DATA: list[dict[str, Any]] = [
"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": {
+ "path7": {},
+ "path8": {
+ "source": "172.18.13.2",
+ "destination": "172.18.15.2",
+ "state": "ipsecEstablished",
+ "dpsSessions": {"0": {"active": True}},
+ },
+ }
+ },
+ "internet": {},
+ }
+ },
+ "10.255.0.1": {
+ "dpsGroups": {
+ "internet": {
+ "dpsPaths": {
+ "path6": {
+ "source": "100.64.3.2",
+ "destination": "100.64.1.2",
"state": "ipsecEstablished",
- "source": "172.18.3.2",
- "destination": "172.18.5.2",
"dpsSessions": {"0": {"active": True}},
}
}
- }
+ },
+ "mpls": {},
}
- }
+ },
}
- },
+ }
],
"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.1", "path_group": "internet", "source_address": "100.64.3.2", "destination_address": "100.64.1.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",
+ "name": "failure-expected-path-group-not-found",
"test": VerifySpecificPath,
"eos_data": [
- {"dpsPeers": {}},
- {"dpsPeers": {}},
+ {
+ "dpsPeers": {
+ "10.255.0.2": {
+ "dpsGroups": {"internet": {}},
+ },
+ "10.255.0.1": {"peerName": "", "dpsGroups": {"mpls": {}}},
+ }
+ }
],
"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.1", "path_group": "internet", "source_address": "100.64.3.2", "destination_address": "100.64.1.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`.",
+ "Peer: 10.255.0.1 PathGroup: internet Source: 100.64.3.2 Destination: 100.64.1.2 - No DPS path found for this peer and path group",
+ "Peer: 10.255.0.2 PathGroup: mpls Source: 172.18.13.2 Destination: 172.18.15.2 - No DPS path found for this peer and path group",
],
},
},
+ {
+ "name": "failure-no-router-path-configured",
+ "test": VerifySpecificPath,
+ "eos_data": [{"dpsPeers": {}}],
+ "inputs": {"paths": [{"peer": "10.255.0.1", "path_group": "internet", "source_address": "100.64.3.2", "destination_address": "100.64.1.2"}]},
+ "expected": {"result": "failure", "messages": ["Router path-selection not configured"]},
+ },
+ {
+ "name": "failure-no-specific-peer-configured",
+ "test": VerifySpecificPath,
+ "eos_data": [{"dpsPeers": {"10.255.0.2": {}}}],
+ "inputs": {"paths": [{"peer": "10.255.0.1", "path_group": "internet", "source_address": "172.18.3.2", "destination_address": "172.18.5.2"}]},
+ "expected": {"result": "failure", "messages": ["Peer: 10.255.0.1 PathGroup: internet Source: 172.18.3.2 Destination: 172.18.5.2 - Peer not found"]},
+ },
{
"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",
+ "path7": {},
+ "path8": {
"source": "172.18.13.2",
"destination": "172.18.15.2",
- "dpsSessions": {"0": {"active": False}},
- }
+ "state": "ipsecPending",
+ "dpsSessions": {"0": {"active": True}},
+ },
}
- }
+ },
+ "internet": {"dpsPaths": {}},
}
- }
+ },
+ "10.255.0.1": {
+ "dpsGroups": {
+ "internet": {
+ "dpsPaths": {
+ "path6": {"source": "172.18.3.2", "destination": "172.18.5.2", "state": "ipsecPending", "dpsSessions": {"0": {"active": True}}}
+ }
+ },
+ "mpls": {"dpsPaths": {}},
+ }
+ },
}
- },
+ }
],
"inputs": {
"paths": [
@@ -269,8 +287,10 @@ DATA: list[dict[str, Any]] = [
"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`.",
+ "Peer: 10.255.0.1 PathGroup: internet Source: 172.18.3.2 Destination: 172.18.5.2 - Invalid state path - Expected: ipsecEstablished, routeResolved "
+ "Actual: ipsecPending",
+ "Peer: 10.255.0.2 PathGroup: mpls Source: 172.18.13.2 Destination: 172.18.15.2 - Invalid state path - Expected: ipsecEstablished, routeResolved "
+ "Actual: ipsecPending",
],
},
},
@@ -278,37 +298,33 @@ DATA: list[dict[str, Any]] = [
"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",
+ "path8": {
"source": "172.18.13.2",
"destination": "172.18.15.2",
+ "state": "routeResolved",
"dpsSessions": {"0": {"active": False}},
}
}
}
}
- }
+ },
+ "10.255.0.1": {
+ "dpsGroups": {
+ "internet": {
+ "dpsPaths": {
+ "path6": {"source": "172.18.3.2", "destination": "172.18.5.2", "state": "routeResolved", "dpsSessions": {"0": {"active": False}}}
+ }
+ }
+ }
+ },
}
- },
+ }
],
"inputs": {
"paths": [
@@ -319,8 +335,49 @@ DATA: list[dict[str, Any]] = [
"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`.",
+ "Peer: 10.255.0.1 PathGroup: internet Source: 172.18.3.2 Destination: 172.18.5.2 - Telemetry state inactive for this path",
+ "Peer: 10.255.0.2 PathGroup: mpls Source: 172.18.13.2 Destination: 172.18.15.2 - Telemetry state inactive for this path",
+ ],
+ },
+ },
+ {
+ "name": "failure-source-destination-not-configured",
+ "test": VerifySpecificPath,
+ "eos_data": [
+ {
+ "dpsPeers": {
+ "10.255.0.2": {
+ "dpsGroups": {
+ "mpls": {
+ "dpsPaths": {
+ "path8": {"source": "172.18.3.2", "destination": "172.8.15.2", "state": "routeResolved", "dpsSessions": {"0": {"active": False}}}
+ }
+ }
+ }
+ },
+ "10.255.0.1": {
+ "dpsGroups": {
+ "internet": {
+ "dpsPaths": {
+ "path6": {"source": "172.8.3.2", "destination": "172.8.5.2", "state": "routeResolved", "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": [
+ "Peer: 10.255.0.1 PathGroup: internet Source: 172.18.3.2 Destination: 172.18.5.2 - No path matching the source and destination found",
+ "Peer: 10.255.0.2 PathGroup: mpls Source: 172.18.13.2 Destination: 172.18.15.2 - No path matching the source and destination found",
],
},
},
diff --git a/tests/units/anta_tests/test_profiles.py b/tests/units/anta_tests/test_profiles.py
index f822d09..4277a95 100644
--- a/tests/units/anta_tests/test_profiles.py
+++ b/tests/units/anta_tests/test_profiles.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.profiles.py."""
@@ -23,7 +23,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifyUnifiedForwardingTableMode,
"eos_data": [{"uftMode": "2", "urpfEnabled": False, "chipModel": "bcm56870", "l2TableSize": 163840, "l3TableSize": 147456, "lpmTableSize": 32768}],
"inputs": {"mode": 3},
- "expected": {"result": "failure", "messages": ["Device is not running correct UFT mode (expected: 3 / running: 2)"]},
+ "expected": {"result": "failure", "messages": ["Not running the correct UFT mode - Expected: 3 Actual: 2"]},
},
{
"name": "success",
diff --git a/tests/units/anta_tests/test_ptp.py b/tests/units/anta_tests/test_ptp.py
index fc94480..8ff2d6c 100644
--- a/tests/units/anta_tests/test_ptp.py
+++ b/tests/units/anta_tests/test_ptp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Data for testing anta.tests.ptp."""
@@ -39,7 +39,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifyPtpModeStatus,
"eos_data": [{"ptpMode": "ptpDisabled", "ptpIntfSummaries": {}}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The device is not configured as a PTP Boundary Clock: 'ptpDisabled'"]},
+ "expected": {"result": "failure", "messages": ["Not configured as a PTP Boundary Clock - Actual: ptpDisabled"]},
},
{
"name": "skipped",
@@ -99,7 +99,7 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The device is locked to the following Grandmaster: '0x00:1c:73:ff:ff:0a:00:01', which differ from the expected one.",
+ "The device is locked to the incorrect Grandmaster - Expected: 0xec:46:70:ff:fe:00:ff:a8 Actual: 0x00:1c:73:ff:ff:0a:00:01",
],
},
},
@@ -158,7 +158,7 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The device lock is more than 60s old: 157s"]},
+ "expected": {"result": "failure", "messages": ["Lock is more than 60s old - Actual: 157s"]},
},
{
"name": "skipped",
@@ -236,7 +236,9 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [("The device timing offset from master is greater than +/- 1000ns: {'Ethernet27/1': [1200, -1300]}")],
+ "messages": [
+ "Interface: Ethernet27/1 - Timing offset from master is greater than +/- 1000ns: Actual: 1200, -1300",
+ ],
},
},
{
@@ -335,6 +337,6 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following interface(s) are not in a valid PTP state: '['Ethernet53', 'Ethernet1']'"]},
+ "expected": {"result": "failure", "messages": ["The following interface(s) are not in a valid PTP state: Ethernet53, Ethernet1"]},
},
]
diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py
index 472eb7e..6f4d370 100644
--- a/tests/units/anta_tests/test_security.py
+++ b/tests/units/anta_tests/test_security.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.security.py."""
@@ -42,7 +42,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifySSHStatus,
"eos_data": ["SSH per host connection limit is 20\nFIPS status: disabled\n\n"],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Could not find SSH status in returned output."]},
+ "expected": {"result": "failure", "messages": ["Could not find SSH status in returned output"]},
},
{
"name": "failure-ssh-enabled",
@@ -83,14 +83,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifySSHIPv4Acl,
"eos_data": [{"ipAclList": {"aclList": []}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Expected 1 SSH IPv4 ACL(s) in vrf MGMT but got 0"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - SSH IPv4 ACL(s) count mismatch - Expected: 1 Actual: 0"]},
},
{
"name": "failure-wrong-vrf",
"test": VerifySSHIPv4Acl,
"eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SSH", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["SSH IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_SSH']"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Following SSH IPv4 ACL(s) not configured or active: ACL_IPV4_SSH"]},
},
{
"name": "success",
@@ -104,14 +104,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifySSHIPv6Acl,
"eos_data": [{"ipv6AclList": {"aclList": []}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Expected 1 SSH IPv6 ACL(s) in vrf MGMT but got 0"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - SSH IPv6 ACL(s) count mismatch - Expected: 1 Actual: 0"]},
},
{
"name": "failure-wrong-vrf",
"test": VerifySSHIPv6Acl,
"eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SSH", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["SSH IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_SSH']"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Following SSH IPv6 ACL(s) not configured or active: ACL_IPV6_SSH"]},
},
{
"name": "success",
@@ -192,7 +192,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"profile": "API_SSL_Profile"},
- "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is not configured"]},
+ "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile API_SSL_Profile is not configured"]},
},
{
"name": "failure-misconfigured-invalid",
@@ -209,7 +209,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"profile": "API_SSL_Profile"},
- "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is misconfigured or invalid"]},
+ "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile API_SSL_Profile is misconfigured or invalid"]},
},
{
"name": "success",
@@ -223,14 +223,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifyAPIIPv4Acl,
"eos_data": [{"ipAclList": {"aclList": []}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Expected 1 eAPI IPv4 ACL(s) in vrf MGMT but got 0"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - eAPI IPv4 ACL(s) count mismatch - Expected: 1 Actual: 0"]},
},
{
"name": "failure-wrong-vrf",
"test": VerifyAPIIPv4Acl,
"eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_API", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["eAPI IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_API']"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Following eAPI IPv4 ACL(s) not configured or active: ACL_IPV4_API"]},
},
{
"name": "success",
@@ -244,14 +244,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifyAPIIPv6Acl,
"eos_data": [{"ipv6AclList": {"aclList": []}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Expected 1 eAPI IPv6 ACL(s) in vrf MGMT but got 0"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - eAPI IPv6 ACL(s) count mismatch - Expected: 1 Actual: 0"]},
},
{
"name": "failure-wrong-vrf",
"test": VerifyAPIIPv6Acl,
"eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_API", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["eAPI IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_API']"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Following eAPI IPv6 ACL(s) not configured or active: ACL_IPV6_API"]},
},
{
"name": "success",
@@ -341,7 +341,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["SSL certificate 'ARISTA_ROOT_CA.crt', is not configured.\n"],
+ "messages": ["Certificate: ARISTA_ROOT_CA.crt - Not found"],
},
},
{
@@ -366,13 +366,6 @@ DATA: list[dict[str, Any]] = [
],
"inputs": {
"certificates": [
- {
- "certificate_name": "ARISTA_SIGNING_CA.crt",
- "expiry_threshold": 30,
- "common_name": "AristaIT-ICA ECDSA Issuing Cert Authority",
- "encryption_algorithm": "ECDSA",
- "key_size": 256,
- },
{
"certificate_name": "ARISTA_ROOT_CA.crt",
"expiry_threshold": 30,
@@ -384,7 +377,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["SSL certificate 'ARISTA_SIGNING_CA.crt', is not configured.\n", "SSL certificate `ARISTA_ROOT_CA.crt` is expired.\n"],
+ "messages": ["Certificate: ARISTA_ROOT_CA.crt - certificate expired"],
},
},
{
@@ -403,7 +396,7 @@ DATA: list[dict[str, Any]] = [
},
"ARISTA_SIGNING_CA.crt": {
"subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"},
- "notAfter": 1702533518,
+ "notAfter": 1705992709,
"publicKey": {
"encryptionAlgorithm": "ECDSA",
"size": 256,
@@ -435,7 +428,9 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["SSL certificate `ARISTA_SIGNING_CA.crt` is expired.\n", "SSL certificate `ARISTA_ROOT_CA.crt` is about to expire in 25 days."],
+ "messages": [
+ "Certificate: ARISTA_ROOT_CA.crt - set to expire within the threshold - Threshold: 30 days Actual: 25 days",
+ ],
},
},
{
@@ -487,12 +482,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "SSL certificate `ARISTA_SIGNING_CA.crt` is not configured properly:\n"
- "Expected `AristaIT-ICA ECDSA Issuing Cert Authority` as the subject.commonName, but found "
- "`Arista ECDSA Issuing Cert Authority` instead.\n",
- "SSL certificate `ARISTA_ROOT_CA.crt` is not configured properly:\n"
- "Expected `Arista Networks Internal IT Root Cert Authority` as the subject.commonName, "
- "but found `AristaIT-ICA Networks Internal IT Root Cert Authority` instead.\n",
+ "Certificate: ARISTA_SIGNING_CA.crt - incorrect common name - Expected: AristaIT-ICA ECDSA Issuing Cert Authority "
+ "Actual: Arista ECDSA Issuing Cert Authority",
+ "Certificate: ARISTA_ROOT_CA.crt - incorrect common name - Expected: Arista Networks Internal IT Root Cert Authority "
+ "Actual: AristaIT-ICA Networks Internal IT Root Cert Authority",
],
},
},
@@ -545,17 +538,15 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "SSL certificate `ARISTA_SIGNING_CA.crt` is not configured properly:\n"
- "Expected `ECDSA` as the publicKey.encryptionAlgorithm, but found `RSA` instead.\n"
- "Expected `256` as the publicKey.size, but found `4096` instead.\n",
- "SSL certificate `ARISTA_ROOT_CA.crt` is not configured properly:\n"
- "Expected `RSA` as the publicKey.encryptionAlgorithm, but found `ECDSA` instead.\n"
- "Expected `4096` as the publicKey.size, but found `256` instead.\n",
+ "Certificate: ARISTA_SIGNING_CA.crt - incorrect encryption algorithm - Expected: ECDSA Actual: RSA",
+ "Certificate: ARISTA_SIGNING_CA.crt - incorrect public key - Expected: 256 Actual: 4096",
+ "Certificate: ARISTA_ROOT_CA.crt - incorrect encryption algorithm - Expected: RSA Actual: ECDSA",
+ "Certificate: ARISTA_ROOT_CA.crt - incorrect public key - Expected: 4096 Actual: 256",
],
},
},
{
- "name": "failure-missing-actual-output",
+ "name": "failure-missing-algorithm-details",
"test": VerifyAPISSLCertificate,
"eos_data": [
{
@@ -595,12 +586,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "SSL certificate `ARISTA_SIGNING_CA.crt` is not configured properly:\n"
- "Expected `ECDSA` as the publicKey.encryptionAlgorithm, but it was not found in the actual output.\n"
- "Expected `256` as the publicKey.size, but it was not found in the actual output.\n",
- "SSL certificate `ARISTA_ROOT_CA.crt` is not configured properly:\n"
- "Expected `RSA` as the publicKey.encryptionAlgorithm, but it was not found in the actual output.\n"
- "Expected `4096` as the publicKey.size, but it was not found in the actual output.\n",
+ "Certificate: ARISTA_SIGNING_CA.crt - incorrect encryption algorithm - Expected: ECDSA Actual: Not found",
+ "Certificate: ARISTA_SIGNING_CA.crt - incorrect public key - Expected: 256 Actual: Not found",
+ "Certificate: ARISTA_ROOT_CA.crt - incorrect encryption algorithm - Expected: RSA Actual: Not found",
+ "Certificate: ARISTA_ROOT_CA.crt - incorrect public key - Expected: 4096 Actual: Not found",
],
},
},
@@ -651,12 +640,26 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Expected `Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n"
- "that can be found in the LICENSE file.` as the login banner, but found `Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is "
- "governed by the Apache License 2.0\nthat can be found in the LICENSE file.` instead."
+ "Incorrect login banner configured - Expected: Copyright (c) 2023-2024 Arista Networks, Inc.\n"
+ "Use of this source code is governed by the Apache License 2.0\nthat can be found in the LICENSE file. "
+ "Actual: Copyright (c) 2023 Arista Networks, Inc.\n"
+ "Use of this source code is governed by the Apache License 2.0\nthat can be found in the LICENSE file."
],
},
},
+ {
+ "name": "failure-login-banner-not-configured",
+ "test": VerifyBannerLogin,
+ "eos_data": [{"loginBanner": ""}],
+ "inputs": {
+ "login_banner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n"
+ "that can be found in the LICENSE file."
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["Login banner is not configured"],
+ },
+ },
{
"name": "success",
"test": VerifyBannerMotd,
@@ -704,12 +707,26 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Expected `Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n"
- "that can be found in the LICENSE file.` as the motd banner, but found `Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is "
- "governed by the Apache License 2.0\nthat can be found in the LICENSE file.` instead."
+ "Incorrect MOTD banner configured - Expected: Copyright (c) 2023-2024 Arista Networks, Inc.\n"
+ "Use of this source code is governed by the Apache License 2.0\nthat can be found in the LICENSE file. "
+ "Actual: Copyright (c) 2023 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n"
+ "that can be found in the LICENSE file."
],
},
},
+ {
+ "name": "failure-login-banner-not-configured",
+ "test": VerifyBannerMotd,
+ "eos_data": [{"motd": ""}],
+ "inputs": {
+ "motd_banner": "Copyright (c) 2023-2024 Arista Networks, Inc.\nUse of this source code is governed by the Apache License 2.0\n"
+ "that can be found in the LICENSE file."
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["MOTD banner is not configured"],
+ },
+ },
{
"name": "success",
"test": VerifyIPv4ACL,
@@ -717,22 +734,20 @@ DATA: list[dict[str, Any]] = [
{
"aclList": [
{
+ "name": "default-control-plane-acl",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit ip any any tracked", "sequenceNumber": 20},
{"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 30},
],
- }
- ]
- },
- {
- "aclList": [
+ },
{
+ "name": "LabTest",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit tcp any any range 5900 5910", "sequenceNumber": 20},
],
- }
+ },
]
},
],
@@ -754,6 +769,24 @@ DATA: list[dict[str, Any]] = [
},
"expected": {"result": "success"},
},
+ {
+ "name": "failure-no-acl-list",
+ "test": VerifyIPv4ACL,
+ "eos_data": [
+ {"aclList": []},
+ ],
+ "inputs": {
+ "ipv4_access_lists": [
+ {
+ "name": "default-control-plane-acl",
+ "entries": [
+ {"sequence": 10, "action": "permit icmp any any"},
+ ],
+ },
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["No Access Control List (ACL) configured"]},
+ },
{
"name": "failure-acl-not-found",
"test": VerifyIPv4ACL,
@@ -761,6 +794,7 @@ DATA: list[dict[str, Any]] = [
{
"aclList": [
{
+ "name": "default-control-plane-acl",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit ip any any tracked", "sequenceNumber": 20},
@@ -769,7 +803,6 @@ DATA: list[dict[str, Any]] = [
}
]
},
- {"aclList": []},
],
"inputs": {
"ipv4_access_lists": [
@@ -787,7 +820,7 @@ DATA: list[dict[str, Any]] = [
},
]
},
- "expected": {"result": "failure", "messages": ["LabTest: Not found"]},
+ "expected": {"result": "failure", "messages": ["ACL name: LabTest - Not configured"]},
},
{
"name": "failure-sequence-not-found",
@@ -796,22 +829,20 @@ DATA: list[dict[str, Any]] = [
{
"aclList": [
{
+ "name": "default-control-plane-acl",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit ip any any tracked", "sequenceNumber": 20},
{"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 40},
],
- }
- ]
- },
- {
- "aclList": [
+ },
{
+ "name": "LabTest",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30},
],
- }
+ },
]
},
],
@@ -833,7 +864,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["default-control-plane-acl:\nSequence number `30` is not found.\n", "LabTest:\nSequence number `20` is not found.\n"],
+ "messages": ["ACL name: default-control-plane-acl Sequence: 30 - Not configured", "ACL name: LabTest Sequence: 20 - Not configured"],
},
},
{
@@ -843,22 +874,20 @@ DATA: list[dict[str, Any]] = [
{
"aclList": [
{
+ "name": "default-control-plane-acl",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit ip any any tracked", "sequenceNumber": 20},
{"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30},
],
- }
- ]
- },
- {
- "aclList": [
+ },
{
+ "name": "LabTest",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 20},
],
- }
+ },
]
},
],
@@ -881,9 +910,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "default-control-plane-acl:\n"
- "Expected `permit udp any any eq bfd ttl eq 255` as sequence number 30 action but found `permit tcp any any range 5900 5910` instead.\n",
- "LabTest:\nExpected `permit tcp any any range 5900 5910` as sequence number 20 action but found `permit udp any any eq bfd ttl eq 255` instead.\n",
+ "ACL name: default-control-plane-acl Sequence: 30 - action mismatch - Expected: permit udp any any eq bfd ttl eq 255 "
+ "Actual: permit tcp any any range 5900 5910",
+ "ACL name: LabTest Sequence: 20 - action mismatch - Expected: permit tcp any any range 5900 5910 Actual: permit udp any any eq bfd ttl eq 255",
],
},
},
@@ -894,6 +923,7 @@ DATA: list[dict[str, Any]] = [
{
"aclList": [
{
+ "name": "default-control-plane-acl",
"sequence": [
{"text": "permit icmp any any", "sequenceNumber": 10},
{"text": "permit ip any any tracked", "sequenceNumber": 40},
@@ -902,7 +932,6 @@ DATA: list[dict[str, Any]] = [
}
]
},
- {"aclList": []},
],
"inputs": {
"ipv4_access_lists": [
@@ -923,9 +952,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "default-control-plane-acl:\nSequence number `20` is not found.\n"
- "Expected `permit udp any any eq bfd ttl eq 255` as sequence number 30 action but found `permit tcp any any range 5900 5910` instead.\n",
- "LabTest: Not found",
+ "ACL name: default-control-plane-acl Sequence: 20 - Not configured",
+ "ACL name: default-control-plane-acl Sequence: 30 - action mismatch - Expected: permit udp any any eq bfd ttl eq 255 "
+ "Actual: permit tcp any any range 5900 5910",
+ "ACL name: LabTest - Not configured",
],
},
},
@@ -952,7 +982,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifyIPSecConnHealth,
"eos_data": [{"connections": {}}],
"inputs": {},
- "expected": {"result": "failure", "messages": ["No IPv4 security connection configured."]},
+ "expected": {"result": "failure", "messages": ["No IPv4 security connection configured"]},
},
{
"name": "failure-not-established",
@@ -974,9 +1004,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The following IPv4 security connections are not established:\n"
- "source:172.18.3.2 destination:172.18.2.2 vrf:default\n"
- "source:100.64.3.2 destination:100.64.5.2 vrf:Guest."
+ "Source: 172.18.3.2 Destination: 172.18.2.2 VRF: default - IPv4 security connection not established",
+ "Source: 100.64.3.2 Destination: 100.64.5.2 VRF: Guest - IPv4 security connection not established",
],
},
},
@@ -1127,10 +1156,10 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.255.0.1 VRF: default Source: 172.18.3.2 Destination: 172.18.2.2 - Connection down - Expected: Established, Actual: Idle",
- "Peer: 10.255.0.1 VRF: default Source: 100.64.2.2 Destination: 100.64.1.2 - Connection down - Expected: Established, Actual: Idle",
- "Peer: 10.255.0.2 VRF: MGMT Source: 100.64.2.2 Destination: 100.64.1.2 - Connection down - Expected: Established, Actual: Idle",
- "Peer: 10.255.0.2 VRF: MGMT Source: 172.18.2.2 Destination: 172.18.1.2 - Connection down - Expected: Established, Actual: Idle",
+ "Peer: 10.255.0.1 VRF: default Source: 172.18.3.2 Destination: 172.18.2.2 - Connection down - Expected: Established Actual: Idle",
+ "Peer: 10.255.0.1 VRF: default Source: 100.64.2.2 Destination: 100.64.1.2 - Connection down - Expected: Established Actual: Idle",
+ "Peer: 10.255.0.2 VRF: MGMT Source: 100.64.2.2 Destination: 100.64.1.2 - Connection down - Expected: Established Actual: Idle",
+ "Peer: 10.255.0.2 VRF: MGMT Source: 172.18.2.2 Destination: 172.18.1.2 - Connection down - Expected: Established Actual: Idle",
],
},
},
@@ -1190,8 +1219,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Peer: 10.255.0.1 VRF: default Source: 172.18.3.2 Destination: 172.18.2.2 - Connection down - Expected: Established, Actual: Idle",
- "Peer: 10.255.0.1 VRF: default Source: 100.64.3.2 Destination: 100.64.2.2 - Connection down - Expected: Established, Actual: Idle",
+ "Peer: 10.255.0.1 VRF: default Source: 172.18.3.2 Destination: 172.18.2.2 - Connection down - Expected: Established Actual: Idle",
+ "Peer: 10.255.0.1 VRF: default Source: 100.64.3.2 Destination: 100.64.2.2 - Connection down - Expected: Established Actual: Idle",
"Peer: 10.255.0.2 VRF: default Source: 100.64.4.2 Destination: 100.64.1.2 - Connection not found.",
"Peer: 10.255.0.2 VRF: default Source: 172.18.4.2 Destination: 172.18.1.2 - Connection not found.",
],
@@ -1209,7 +1238,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifyHardwareEntropy,
"eos_data": [{"cpuModel": "2.20GHz", "cryptoModule": "Crypto Module v3.0", "hardwareEntropyEnabled": False, "blockedNetworkProtocols": []}],
"inputs": {},
- "expected": {"result": "failure", "messages": ["Hardware entropy generation is disabled."]},
+ "expected": {"result": "failure", "messages": ["Hardware entropy generation is disabled"]},
},
]
diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py
index 639c5c6..2b9011c 100644
--- a/tests/units/anta_tests/test_services.py
+++ b/tests/units/anta_tests/test_services.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.services.py."""
@@ -25,7 +25,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"hostname": "s1-spine1"},
"expected": {
"result": "failure",
- "messages": ["Expected `s1-spine1` as the hostname, but found `s1-spine2` instead."],
+ "messages": ["Incorrect Hostname - Expected: s1-spine1 Actual: s1-spine2"],
},
},
{
@@ -88,7 +88,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Server 10.14.0.10 (VRF: default, Priority: 0) - Not configured", "Server 10.14.0.21 (VRF: MGMT, Priority: 1) - Not configured"],
+ "messages": ["Server 10.14.0.10 VRF: default Priority: 0 - Not configured", "Server 10.14.0.21 VRF: MGMT Priority: 1 - Not configured"],
},
},
{
@@ -109,9 +109,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Server 10.14.0.1 (VRF: CS, Priority: 0) - Incorrect priority - Priority: 1",
- "Server 10.14.0.11 (VRF: default, Priority: 0) - Not configured",
- "Server 10.14.0.110 (VRF: MGMT, Priority: 0) - Not configured",
+ "Server 10.14.0.1 VRF: CS Priority: 0 - Incorrect priority - Priority: 1",
+ "Server 10.14.0.11 VRF: default Priority: 0 - Not configured",
+ "Server 10.14.0.110 VRF: MGMT Priority: 0 - Not configured",
],
},
},
@@ -147,7 +147,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}, {"reason": "tapagg", "interval": 30}]},
"expected": {
"result": "failure",
- "messages": ["`tapagg`: Not found."],
+ "messages": ["Reason: tapagg Status: Enabled Interval: 30 - Not found"],
},
},
{
@@ -165,7 +165,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}]},
"expected": {
"result": "failure",
- "messages": ["`acl`:\nExpected `Enabled` as the status, but found `Disabled` instead."],
+ "messages": ["Reason: acl Status: Enabled Interval: 300 - Incorrect configuration - Status: Disabled Interval: 300"],
},
},
{
@@ -183,7 +183,9 @@ DATA: list[dict[str, Any]] = [
"inputs": {"reasons": [{"reason": "acl", "interval": 30}, {"reason": "arp-inspection", "interval": 30}]},
"expected": {
"result": "failure",
- "messages": ["`acl`:\nExpected `30` as the interval, but found `300` instead."],
+ "messages": [
+ "Reason: acl Status: Enabled Interval: 30 - Incorrect configuration - Status: Enabled Interval: 300",
+ ],
},
},
{
@@ -202,9 +204,9 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "`acl`:\nExpected `30` as the interval, but found `300` instead.\nExpected `Enabled` as the status, but found `Disabled` instead.",
- "`arp-inspection`:\nExpected `300` as the interval, but found `30` instead.",
- "`tapagg`: Not found.",
+ "Reason: acl Status: Enabled Interval: 30 - Incorrect configuration - Status: Disabled Interval: 300",
+ "Reason: arp-inspection Status: Enabled Interval: 300 - Incorrect configuration - Status: Enabled Interval: 30",
+ "Reason: tapagg Status: Enabled Interval: 30 - Not found",
],
},
},
diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py
index e7d8da8..255443d 100644
--- a/tests/units/anta_tests/test_snmp.py
+++ b/tests/units/anta_tests/test_snmp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.snmp.py."""
@@ -10,11 +10,16 @@ from typing import Any
from anta.tests.snmp import (
VerifySnmpContact,
VerifySnmpErrorCounters,
+ VerifySnmpGroup,
+ VerifySnmpHostLogging,
VerifySnmpIPv4Acl,
VerifySnmpIPv6Acl,
VerifySnmpLocation,
+ VerifySnmpNotificationHost,
VerifySnmpPDUCounters,
+ VerifySnmpSourceInterface,
VerifySnmpStatus,
+ VerifySnmpUser,
)
from tests.units.anta_tests import test
@@ -31,14 +36,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifySnmpStatus,
"eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": True}],
"inputs": {"vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf MGMT"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - SNMP agent disabled"]},
},
{
"name": "failure-disabled",
"test": VerifySnmpStatus,
"eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": False}],
"inputs": {"vrf": "default"},
- "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf default"]},
+ "expected": {"result": "failure", "messages": ["VRF: default - SNMP agent disabled"]},
},
{
"name": "success",
@@ -52,14 +57,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifySnmpIPv4Acl,
"eos_data": [{"ipAclList": {"aclList": []}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Expected 1 SNMP IPv4 ACL(s) in vrf MGMT but got 0"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Incorrect SNMP IPv4 ACL(s) - Expected: 1 Actual: 0"]},
},
{
"name": "failure-wrong-vrf",
"test": VerifySnmpIPv4Acl,
"eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SNMP", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["SNMP IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_SNMP']"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Following SNMP IPv4 ACL(s) not configured or active: ACL_IPV4_SNMP"]},
},
{
"name": "success",
@@ -73,14 +78,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifySnmpIPv6Acl,
"eos_data": [{"ipv6AclList": {"aclList": []}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["Expected 1 SNMP IPv6 ACL(s) in vrf MGMT but got 0"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Incorrect SNMP IPv6 ACL(s) - Expected: 1 Actual: 0"]},
},
{
"name": "failure-wrong-vrf",
"test": VerifySnmpIPv6Acl,
"eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SNMP", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}],
"inputs": {"number": 1, "vrf": "MGMT"},
- "expected": {"result": "failure", "messages": ["SNMP IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_SNMP']"]},
+ "expected": {"result": "failure", "messages": ["VRF: MGMT - Following SNMP IPv6 ACL(s) not configured or active: ACL_IPV6_SNMP"]},
},
{
"name": "success",
@@ -104,7 +109,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"location": "New York"},
"expected": {
"result": "failure",
- "messages": ["Expected `New York` as the location, but found `Europe` instead."],
+ "messages": ["Incorrect SNMP location - Expected: New York Actual: Europe"],
},
},
{
@@ -118,7 +123,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"location": "New York"},
"expected": {
"result": "failure",
- "messages": ["SNMP location is not configured."],
+ "messages": ["SNMP location is not configured"],
},
},
{
@@ -143,7 +148,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"contact": "Bob@example.com"},
"expected": {
"result": "failure",
- "messages": ["Expected `Bob@example.com` as the contact, but found `Jon@example.com` instead."],
+ "messages": ["Incorrect SNMP contact - Expected: Bob@example.com Actual: Jon@example.com"],
},
},
{
@@ -157,7 +162,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"contact": "Bob@example.com"},
"expected": {
"result": "failure",
- "messages": ["SNMP contact is not configured."],
+ "messages": ["SNMP contact is not configured"],
},
},
{
@@ -203,7 +208,7 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {},
- "expected": {"result": "failure", "messages": ["SNMP counters not found."]},
+ "expected": {"result": "failure", "messages": ["SNMP counters not found"]},
},
{
"name": "failure-incorrect-counters",
@@ -222,7 +227,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {},
"expected": {
"result": "failure",
- "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 0, 'inSetPdus': 0}"],
+ "messages": ["The following SNMP PDU counters are not found or have zero PDU counters: inGetPdus, inSetPdus"],
},
},
{
@@ -240,7 +245,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"pdus": ["inGetPdus", "outTrapPdus"]},
"expected": {
"result": "failure",
- "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 'Not Found', 'outTrapPdus': 'Not Found'}"],
+ "messages": ["The following SNMP PDU counters are not found or have zero PDU counters: inGetPdus, outTrapPdus"],
},
},
{
@@ -292,7 +297,7 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {},
- "expected": {"result": "failure", "messages": ["SNMP counters not found."]},
+ "expected": {"result": "failure", "messages": ["SNMP counters not found"]},
},
{
"name": "failure-incorrect-counters",
@@ -312,10 +317,796 @@ DATA: list[dict[str, Any]] = [
}
],
"inputs": {},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following SNMP error counters are not found or have non-zero error counters: inParseErrs, inVersionErrs, outBadValueErrs"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpHostLogging,
+ "eos_data": [
+ {
+ "logging": {
+ "loggingEnabled": True,
+ "hosts": {
+ "192.168.1.100": {"port": 162, "vrf": ""},
+ "192.168.1.101": {"port": 162, "vrf": "MGMT"},
+ "snmp-server-01": {"port": 162, "vrf": "default"},
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default"},
+ {"hostname": "192.168.1.101", "vrf": "MGMT"},
+ {"hostname": "snmp-server-01", "vrf": "default"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-logging-disabled",
+ "test": VerifySnmpHostLogging,
+ "eos_data": [{"logging": {"loggingEnabled": False}}],
+ "inputs": {"hosts": [{"hostname": "192.168.1.100", "vrf": "default"}, {"hostname": "192.168.1.101", "vrf": "MGMT"}]},
+ "expected": {"result": "failure", "messages": ["SNMP logging is disabled"]},
+ },
+ {
+ "name": "failure-mismatch-vrf",
+ "test": VerifySnmpHostLogging,
+ "eos_data": [{"logging": {"loggingEnabled": True, "hosts": {"192.168.1.100": {"port": 162, "vrf": "MGMT"}, "192.168.1.101": {"port": 162, "vrf": "Test"}}}}],
+ "inputs": {"hosts": [{"hostname": "192.168.1.100", "vrf": "default"}, {"hostname": "192.168.1.101", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["Host: 192.168.1.100 VRF: default - Incorrect VRF - Actual: MGMT", "Host: 192.168.1.101 VRF: MGMT - Incorrect VRF - Actual: Test"],
+ },
+ },
+ {
+ "name": "failure-host-not-configured",
+ "test": VerifySnmpHostLogging,
+ "eos_data": [{"logging": {"loggingEnabled": True, "hosts": {"192.168.1.100": {"port": 162, "vrf": "MGMT"}, "192.168.1.103": {"port": 162, "vrf": "Test"}}}}],
+ "inputs": {"hosts": [{"hostname": "192.168.1.101", "vrf": "default"}, {"hostname": "192.168.1.102", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["Host: 192.168.1.101 VRF: default - Not configured", "Host: 192.168.1.102 VRF: MGMT - Not configured"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpUser,
+ "eos_data": [
+ {
+ "usersByVersion": {
+ "v1": {
+ "users": {
+ "Test1": {
+ "groupName": "TestGroup1",
+ },
+ }
+ },
+ "v2c": {
+ "users": {
+ "Test2": {
+ "groupName": "TestGroup2",
+ },
+ }
+ },
+ "v3": {
+ "users": {
+ "Test3": {
+ "groupName": "TestGroup3",
+ "v3Params": {"authType": "SHA-384", "privType": "AES-128"},
+ },
+ "Test4": {"groupName": "TestGroup3", "v3Params": {"authType": "SHA-512", "privType": "AES-192"}},
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_users": [
+ {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+ {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+ {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"},
+ {"username": "Test4", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifySnmpUser,
+ "eos_data": [
+ {
+ "usersByVersion": {
+ "v3": {
+ "users": {
+ "Test3": {
+ "groupName": "TestGroup3",
+ "v3Params": {"authType": "SHA-384", "privType": "AES-128"},
+ },
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_users": [
+ {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+ {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+ {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"},
+ {"username": "Test4", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"},
+ ]
+ },
"expected": {
"result": "failure",
"messages": [
- "The following SNMP error counters are not found or have non-zero error counters:\n{'inVersionErrs': 1, 'inParseErrs': 2, 'outBadValueErrs': 2}"
+ "User: Test1 Group: TestGroup1 Version: v1 - Not found",
+ "User: Test2 Group: TestGroup2 Version: v2c - Not found",
+ "User: Test4 Group: TestGroup3 Version: v3 - Not found",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-group",
+ "test": VerifySnmpUser,
+ "eos_data": [
+ {
+ "usersByVersion": {
+ "v1": {
+ "users": {
+ "Test1": {
+ "groupName": "TestGroup2",
+ },
+ }
+ },
+ "v2c": {
+ "users": {
+ "Test2": {
+ "groupName": "TestGroup1",
+ },
+ }
+ },
+ "v3": {},
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_users": [
+ {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+ {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "User: Test1 Group: TestGroup1 Version: v1 - Incorrect user group - Actual: TestGroup2",
+ "User: Test2 Group: TestGroup2 Version: v2c - Incorrect user group - Actual: TestGroup1",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-auth-encryption",
+ "test": VerifySnmpUser,
+ "eos_data": [
+ {
+ "usersByVersion": {
+ "v1": {
+ "users": {
+ "Test1": {
+ "groupName": "TestGroup1",
+ },
+ }
+ },
+ "v2c": {
+ "users": {
+ "Test2": {
+ "groupName": "TestGroup2",
+ },
+ }
+ },
+ "v3": {
+ "users": {
+ "Test3": {
+ "groupName": "TestGroup3",
+ "v3Params": {"authType": "SHA-512", "privType": "AES-192"},
+ },
+ "Test4": {"groupName": "TestGroup4", "v3Params": {"authType": "SHA-384", "privType": "AES-128"}},
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_users": [
+ {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+ {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+ {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"},
+ {"username": "Test4", "group_name": "TestGroup4", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "User: Test3 Group: TestGroup3 Version: v3 - Incorrect authentication type - Expected: SHA-384 Actual: SHA-512",
+ "User: Test3 Group: TestGroup3 Version: v3 - Incorrect privacy type - Expected: AES-128 Actual: AES-192",
+ "User: Test4 Group: TestGroup4 Version: v3 - Incorrect authentication type - Expected: SHA-512 Actual: SHA-384",
+ "User: Test4 Group: TestGroup4 Version: v3 - Incorrect privacy type - Expected: AES-192 Actual: AES-128",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [
+ {
+ "hosts": [
+ {
+ "hostname": "192.168.1.100",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v3",
+ "v3Params": {"user": "public", "securityLevel": "authNoPriv"},
+ },
+ {
+ "hostname": "192.168.1.101",
+ "port": 162,
+ "vrf": "MGMT",
+ "notificationType": "trap",
+ "protocolVersion": "v2c",
+ "v1v2cParams": {"communityString": "public"},
+ },
+ ]
+ }
+ ],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"},
+ {"hostname": "192.168.1.101", "vrf": "MGMT", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [{"hosts": []}],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"},
+ {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"},
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["No SNMP host is configured"]},
+ },
+ {
+ "name": "failure-details-host-not-found",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [
+ {
+ "hosts": [
+ {
+ "hostname": "192.168.1.100",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v3",
+ "v3Params": {"user": "public", "securityLevel": "authNoPriv"},
+ },
+ ]
+ }
+ ],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"},
+ {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"},
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["Host: 192.168.1.101 VRF: default Version: v2c - Not configured"]},
+ },
+ {
+ "name": "failure-incorrect-notification-type",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [
+ {
+ "hosts": [
+ {
+ "hostname": "192.168.1.100",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v3",
+ "v3Params": {"user": "public", "securityLevel": "authNoPriv"},
+ },
+ {
+ "hostname": "192.168.1.101",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "inform",
+ "protocolVersion": "v2c",
+ "v1v2cParams": {"communityString": "public"},
+ },
+ ]
+ }
+ ],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "inform", "version": "v3", "udp_port": 162, "user": "public"},
+ {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Host: 192.168.1.100 VRF: default - Incorrect notification type - Expected: inform Actual: trap",
+ "Host: 192.168.1.101 VRF: default - Incorrect notification type - Expected: trap Actual: inform",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-udp-port",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [
+ {
+ "hosts": [
+ {
+ "hostname": "192.168.1.100",
+ "port": 163,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v3",
+ "v3Params": {"user": "public", "securityLevel": "authNoPriv"},
+ },
+ {
+ "hostname": "192.168.1.101",
+ "port": 164,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v2c",
+ "v1v2cParams": {"communityString": "public"},
+ },
+ ]
+ }
+ ],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"},
+ {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Host: 192.168.1.100 VRF: default - Incorrect UDP port - Expected: 162 Actual: 163",
+ "Host: 192.168.1.101 VRF: default - Incorrect UDP port - Expected: 162 Actual: 164",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-community-string-version-v1-v2c",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [
+ {
+ "hosts": [
+ {
+ "hostname": "192.168.1.100",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v1",
+ "v1v2cParams": {"communityString": "private"},
+ },
+ {
+ "hostname": "192.168.1.101",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v2c",
+ "v1v2cParams": {"communityString": "private"},
+ },
+ ]
+ }
+ ],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v1", "udp_port": 162, "community_string": "public"},
+ {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Host: 192.168.1.100 VRF: default Version: v1 - Incorrect community string - Expected: public Actual: private",
+ "Host: 192.168.1.101 VRF: default Version: v2c - Incorrect community string - Expected: public Actual: private",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-user-for-version-v3",
+ "test": VerifySnmpNotificationHost,
+ "eos_data": [
+ {
+ "hosts": [
+ {
+ "hostname": "192.168.1.100",
+ "port": 162,
+ "vrf": "",
+ "notificationType": "trap",
+ "protocolVersion": "v3",
+ "v3Params": {"user": "private", "securityLevel": "authNoPriv"},
+ }
+ ]
+ }
+ ],
+ "inputs": {
+ "notification_hosts": [
+ {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"},
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["Host: 192.168.1.100 VRF: default Version: v3 - Incorrect user - Expected: public Actual: private"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpSourceInterface,
+ "eos_data": [
+ {
+ "srcIntf": {"sourceInterfaces": {"default": "Ethernet1", "MGMT": "Management0"}},
+ }
+ ],
+ "inputs": {"interfaces": [{"interface": "Ethernet1", "vrf": "default"}, {"interface": "Management0", "vrf": "MGMT"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifySnmpSourceInterface,
+ "eos_data": [
+ {
+ "srcIntf": {},
+ }
+ ],
+ "inputs": {"interfaces": [{"interface": "Ethernet1", "vrf": "default"}, {"interface": "Management0", "vrf": "MGMT"}]},
+ "expected": {"result": "failure", "messages": ["SNMP source interface(s) not configured"]},
+ },
+ {
+ "name": "failure-incorrect-interfaces",
+ "test": VerifySnmpSourceInterface,
+ "eos_data": [
+ {
+ "srcIntf": {
+ "sourceInterfaces": {
+ "default": "Management0",
+ }
+ },
+ }
+ ],
+ "inputs": {"interfaces": [{"interface": "Ethernet1", "vrf": "default"}, {"interface": "Management0", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Source Interface: Ethernet1 VRF: default - Incorrect source interface - Actual: Management0",
+ "Source Interface: Management0 VRF: MGMT - Not configured",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpGroup,
+ "eos_data": [
+ {
+ "groups": {
+ "Group1": {
+ "versions": {
+ "v1": {
+ "secModel": "v1",
+ "readView": "group_read_1",
+ "readViewConfig": True,
+ "writeView": "group_write_1",
+ "writeViewConfig": True,
+ "notifyView": "group_notify_1",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ "Group2": {
+ "versions": {
+ "v2c": {
+ "secModel": "v2c",
+ "readView": "group_read_2",
+ "readViewConfig": True,
+ "writeView": "group_write_2",
+ "writeViewConfig": True,
+ "notifyView": "group_notify_2",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ "Group3": {
+ "versions": {
+ "v3": {
+ "secModel": "v3Auth",
+ "readView": "group_read_3",
+ "readViewConfig": True,
+ "writeView": "group_write_3",
+ "writeViewConfig": True,
+ "notifyView": "group_notify_3",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_groups": [
+ {"group_name": "Group1", "version": "v1", "read_view": "group_read_1", "write_view": "group_write_1", "notify_view": "group_notify_1"},
+ {"group_name": "Group2", "version": "v2c", "read_view": "group_read_2", "write_view": "group_write_2", "notify_view": "group_notify_2"},
+ {
+ "group_name": "Group3",
+ "version": "v3",
+ "read_view": "group_read_3",
+ "write_view": "group_write_3",
+ "notify_view": "group_notify_3",
+ "authentication": "auth",
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-view",
+ "test": VerifySnmpGroup,
+ "eos_data": [
+ {
+ "groups": {
+ "Group1": {
+ "versions": {
+ "v1": {
+ "secModel": "v1",
+ "readView": "group_read",
+ "readViewConfig": True,
+ "writeView": "group_write",
+ "writeViewConfig": True,
+ "notifyView": "group_notify",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ "Group2": {
+ "versions": {
+ "v2c": {
+ "secModel": "v2c",
+ "readView": "group_read",
+ "readViewConfig": True,
+ "writeView": "group_write",
+ "writeViewConfig": True,
+ "notifyView": "group_notify",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ "Group3": {
+ "versions": {
+ "v3": {
+ "secModel": "v3NoAuth",
+ "readView": "group_read",
+ "readViewConfig": True,
+ "writeView": "group_write",
+ "writeViewConfig": True,
+ "notifyView": "group_notify",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_groups": [
+ {"group_name": "Group1", "version": "v1", "read_view": "group_read_1", "write_view": "group_write_1", "notify_view": "group_notify_1"},
+ {"group_name": "Group2", "version": "v2c", "read_view": "group_read_2", "notify_view": "group_notify_2"},
+ {
+ "group_name": "Group3",
+ "version": "v3",
+ "read_view": "group_read_3",
+ "write_view": "group_write_3",
+ "notify_view": "group_notify_3",
+ "authentication": "noauth",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Group: Group1 Version: v1 - Incorrect Read view - Expected: group_read_1 Actual: group_read",
+ "Group: Group1 Version: v1 - Incorrect Write view - Expected: group_write_1 Actual: group_write",
+ "Group: Group1 Version: v1 - Incorrect Notify view - Expected: group_notify_1 Actual: group_notify",
+ "Group: Group2 Version: v2c - Incorrect Read view - Expected: group_read_2 Actual: group_read",
+ "Group: Group2 Version: v2c - Incorrect Notify view - Expected: group_notify_2 Actual: group_notify",
+ "Group: Group3 Version: v3 - Incorrect Read view - Expected: group_read_3 Actual: group_read",
+ "Group: Group3 Version: v3 - Incorrect Write view - Expected: group_write_3 Actual: group_write",
+ "Group: Group3 Version: v3 - Incorrect Notify view - Expected: group_notify_3 Actual: group_notify",
+ ],
+ },
+ },
+ {
+ "name": "failure-view-config-not-found",
+ "test": VerifySnmpGroup,
+ "eos_data": [
+ {
+ "groups": {
+ "Group1": {
+ "versions": {
+ "v1": {
+ "secModel": "v1",
+ "readView": "group_read",
+ "readViewConfig": False,
+ "writeView": "group_write",
+ "writeViewConfig": False,
+ "notifyView": "group_notify",
+ "notifyViewConfig": False,
+ }
+ }
+ },
+ "Group2": {
+ "versions": {
+ "v2c": {
+ "secModel": "v2c",
+ "readView": "group_read",
+ "readViewConfig": False,
+ "writeView": "group_write",
+ "writeViewConfig": False,
+ "notifyView": "group_notify",
+ "notifyViewConfig": False,
+ }
+ }
+ },
+ "Group3": {
+ "versions": {
+ "v3": {
+ "secModel": "v3Priv",
+ "readView": "group_read",
+ "readViewConfig": False,
+ "writeView": "group_write",
+ "writeViewConfig": False,
+ "notifyView": "group_notify",
+ "notifyViewConfig": False,
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_groups": [
+ {"group_name": "Group1", "version": "v1", "read_view": "group_read", "write_view": "group_write", "notify_view": "group_notify"},
+ {"group_name": "Group2", "version": "v2c", "read_view": "group_read", "write_view": "group_write", "notify_view": "group_notify"},
+ {
+ "group_name": "Group3",
+ "version": "v3",
+ "write_view": "group_write",
+ "notify_view": "group_notify",
+ "authentication": "priv",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Group: Group1 Version: v1 Read View: group_read - Not configured",
+ "Group: Group1 Version: v1 Write View: group_write - Not configured",
+ "Group: Group1 Version: v1 Notify View: group_notify - Not configured",
+ "Group: Group2 Version: v2c Read View: group_read - Not configured",
+ "Group: Group2 Version: v2c Write View: group_write - Not configured",
+ "Group: Group2 Version: v2c Notify View: group_notify - Not configured",
+ "Group: Group3 Version: v3 Write View: group_write - Not configured",
+ "Group: Group3 Version: v3 Notify View: group_notify - Not configured",
+ ],
+ },
+ },
+ {
+ "name": "failure-group-version-not-configured",
+ "test": VerifySnmpGroup,
+ "eos_data": [
+ {
+ "groups": {
+ "Group1": {"versions": {"v1": {}}},
+ "Group2": {"versions": {"v2c": {}}},
+ "Group3": {"versions": {"v3": {}}},
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_groups": [
+ {"group_name": "Group1", "version": "v1", "read_view": "group_read_1", "write_view": "group_write_1"},
+ {"group_name": "Group2", "version": "v2c", "read_view": "group_read_2", "write_view": "group_write_2", "notify_view": "group_notify_2"},
+ {
+ "group_name": "Group3",
+ "version": "v3",
+ "read_view": "group_read_3",
+ "write_view": "group_write_3",
+ "notify_view": "group_notify_3",
+ "authentication": "auth",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Group: Group1 Version: v1 - Not configured",
+ "Group: Group2 Version: v2c - Not configured",
+ "Group: Group3 Version: v3 - Not configured",
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-v3-auth-model",
+ "test": VerifySnmpGroup,
+ "eos_data": [
+ {
+ "groups": {
+ "Group3": {
+ "versions": {
+ "v3": {
+ "secModel": "v3Auth",
+ "readView": "group_read",
+ "readViewConfig": True,
+ "writeView": "group_write",
+ "writeViewConfig": True,
+ "notifyView": "group_notify",
+ "notifyViewConfig": True,
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_groups": [
+ {
+ "group_name": "Group3",
+ "version": "v3",
+ "read_view": "group_read",
+ "write_view": "group_write",
+ "notify_view": "group_notify",
+ "authentication": "priv",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Group: Group3 Version: v3 - Incorrect security model - Expected: v3Priv Actual: v3Auth",
+ ],
+ },
+ },
+ {
+ "name": "failure-view-not-configured",
+ "test": VerifySnmpGroup,
+ "eos_data": [
+ {
+ "groups": {
+ "Group3": {"versions": {"v3": {"secModel": "v3NoAuth", "readView": "group_read", "readViewConfig": True, "writeView": "", "notifyView": ""}}},
+ }
+ }
+ ],
+ "inputs": {
+ "snmp_groups": [
+ {
+ "group_name": "Group3",
+ "version": "v3",
+ "read_view": "group_read",
+ "write_view": "group_write",
+ "authentication": "noauth",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Group: Group3 Version: v3 View: write - Not configured",
],
},
},
diff --git a/tests/units/anta_tests/test_software.py b/tests/units/anta_tests/test_software.py
index d2172bb..217cfff 100644
--- a/tests/units/anta_tests/test_software.py
+++ b/tests/units/anta_tests/test_software.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.hardware."""
@@ -35,7 +35,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"versions": ["4.27.1F"]},
- "expected": {"result": "failure", "messages": ["device is running version \"4.27.0F\" not in expected versions: ['4.27.1F']"]},
+ "expected": {"result": "failure", "messages": ["EOS version mismatch - Actual: 4.27.0F not in Expected: 4.27.1F"]},
},
{
"name": "success",
@@ -77,9 +77,8 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"versions": ["v1.17.1", "v1.18.1"]},
- "expected": {"result": "failure", "messages": ["device is running TerminAttr version v1.17.0 and is not in the allowed list: ['v1.17.1', 'v1.18.1']"]},
+ "expected": {"result": "failure", "messages": ["TerminAttr version mismatch - Actual: v1.17.0 not in Expected: v1.17.1, v1.18.1"]},
},
- # TODO: add a test with a real extension?
{
"name": "success-no-extensions",
"test": VerifyEOSExtensions,
@@ -91,11 +90,30 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "success-empty-extension",
+ "name": "success-extensions",
"test": VerifyEOSExtensions,
"eos_data": [
- {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]},
- {"extensions": [""]},
+ {
+ "extensions": {
+ "AristaCloudGateway-1.0.1-1.swix": {
+ "version": "1.0.1",
+ "release": "1",
+ "presence": "present",
+ "status": "installed",
+ "boot": True,
+ "numPackages": 1,
+ "error": False,
+ "vendor": "",
+ "summary": "Arista Cloud Connect",
+ "installedSize": 60532424,
+ "packages": {"AristaCloudGateway-1.0.1-1.x86_64.rpm": {"version": "1.0.1", "release": "1"}},
+ "description": "An extension for Arista Cloud Connect gateway",
+ "affectedAgents": [],
+ "agentsToRestart": [],
+ },
+ }
+ },
+ {"extensions": ["AristaCloudGateway-1.0.1-1.swix"]},
],
"inputs": None,
"expected": {"result": "success"},
@@ -104,10 +122,80 @@ DATA: list[dict[str, Any]] = [
"name": "failure",
"test": VerifyEOSExtensions,
"eos_data": [
- {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]},
- {"extensions": ["dummy"]},
+ {
+ "extensions": {
+ "AristaCloudGateway-1.0.1-1.swix": {
+ "version": "1.0.1",
+ "release": "1",
+ "presence": "present",
+ "status": "installed",
+ "boot": False,
+ "numPackages": 1,
+ "error": False,
+ "vendor": "",
+ "summary": "Arista Cloud Connect",
+ "installedSize": 60532424,
+ "packages": {"AristaCloudGateway-1.0.1-1.x86_64.rpm": {"version": "1.0.1", "release": "1"}},
+ "description": "An extension for Arista Cloud Connect gateway",
+ "affectedAgents": [],
+ "agentsToRestart": [],
+ },
+ }
+ },
+ {"extensions": []},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Missing EOS extensions: installed [] / configured: ['dummy']"]},
+ "expected": {"result": "failure", "messages": ["EOS extensions mismatch - Installed: AristaCloudGateway-1.0.1-1.swix Configured: Not found"]},
+ },
+ {
+ "name": "failure-multiple-extensions",
+ "test": VerifyEOSExtensions,
+ "eos_data": [
+ {
+ "extensions": {
+ "AristaCloudGateway-1.0.1-1.swix": {
+ "version": "1.0.1",
+ "release": "1",
+ "presence": "present",
+ "status": "installed",
+ "boot": False,
+ "numPackages": 1,
+ "error": False,
+ "vendor": "",
+ "summary": "Arista Cloud Connect",
+ "installedSize": 60532424,
+ "packages": {"AristaCloudGateway-1.0.1-1.x86_64.rpm": {"version": "1.0.1", "release": "1"}},
+ "description": "An extension for Arista Cloud Connect gateway",
+ "affectedAgents": [],
+ "agentsToRestart": [],
+ },
+ "EOS-4.33.0F-NDRSensor.swix": {
+ "version": "4.33.0",
+ "release": "39050855.4330F",
+ "presence": "present",
+ "status": "notInstalled",
+ "boot": True,
+ "numPackages": 9,
+ "error": False,
+ "statusDetail": "No RPMs are compatible with current EOS version.",
+ "vendor": "",
+ "summary": "NDR sensor",
+ "installedSize": 0,
+ "packages": {},
+ "description": "NDR sensor provides libraries to generate flow activity records using DPI\nmetadata and IPFIX flow records.",
+ "affectedAgents": [],
+ "agentsToRestart": [],
+ },
+ }
+ },
+ {"extensions": ["AristaCloudGateway-1.0.1-1.swix", "EOS-4.33.0F-NDRSensor.swix"]},
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "EOS extensions mismatch - Installed: AristaCloudGateway-1.0.1-1.swix Configured: AristaCloudGateway-1.0.1-1.swix, EOS-4.33.0F-NDRSensor.swix"
+ ],
+ },
},
]
diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py
index 3742210..539c5e9 100644
--- a/tests/units/anta_tests/test_stp.py
+++ b/tests/units/anta_tests/test_stp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.stp.py."""
@@ -7,7 +7,15 @@ from __future__ import annotations
from typing import Any
-from anta.tests.stp import VerifySTPBlockedPorts, VerifySTPCounters, VerifySTPForwardingPorts, VerifySTPMode, VerifySTPRootPriority, VerifyStpTopologyChanges
+from anta.tests.stp import (
+ VerifySTPBlockedPorts,
+ VerifySTPCounters,
+ VerifySTPDisabledVlans,
+ VerifySTPForwardingPorts,
+ VerifySTPMode,
+ VerifySTPRootPriority,
+ VerifyStpTopologyChanges,
+)
from tests.units.anta_tests import test
DATA: list[dict[str, Any]] = [
@@ -29,7 +37,7 @@ DATA: list[dict[str, Any]] = [
{"spanningTreeVlanInstances": {}},
],
"inputs": {"mode": "rstp", "vlans": [10, 20]},
- "expected": {"result": "failure", "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10, 20]"]},
+ "expected": {"result": "failure", "messages": ["VLAN 10 STP mode: rstp - Not configured", "VLAN 20 STP mode: rstp - Not configured"]},
},
{
"name": "failure-wrong-mode",
@@ -39,7 +47,10 @@ DATA: list[dict[str, Any]] = [
{"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}},
],
"inputs": {"mode": "rstp", "vlans": [10, 20]},
- "expected": {"result": "failure", "messages": ["Wrong STP mode configured for the following VLAN(s): [10, 20]"]},
+ "expected": {
+ "result": "failure",
+ "messages": ["VLAN 10 - Incorrect STP mode - Expected: rstp Actual: mstp", "VLAN 20 - Incorrect STP mode - Expected: rstp Actual: mstp"],
+ },
},
{
"name": "failure-both",
@@ -51,7 +62,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"mode": "rstp", "vlans": [10, 20]},
"expected": {
"result": "failure",
- "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10]", "Wrong STP mode configured for the following VLAN(s): [20]"],
+ "messages": ["VLAN 10 STP mode: rstp - Not configured", "VLAN 20 - Incorrect STP mode - Expected: rstp Actual: mstp"],
},
},
{
@@ -66,7 +77,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifySTPBlockedPorts,
"eos_data": [{"spanningTreeInstances": {"MST0": {"spanningTreeBlockedPorts": ["Ethernet10"]}, "MST10": {"spanningTreeBlockedPorts": ["Ethernet10"]}}}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following ports are blocked by STP: {'MST0': ['Ethernet10'], 'MST10': ['Ethernet10']}"]},
+ "expected": {"result": "failure", "messages": ["STP Instance: MST0 - Blocked ports - Ethernet10", "STP Instance: MST10 - Blocked ports - Ethernet10"]},
},
{
"name": "success",
@@ -76,18 +87,44 @@ DATA: list[dict[str, Any]] = [
"expected": {"result": "success"},
},
{
- "name": "failure",
+ "name": "failure-bpdu-tagged-error-mismatch",
"test": VerifySTPCounters,
"eos_data": [
{
"interfaces": {
"Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "bpduRateLimitCount": 0},
+ "Ethernet11": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "bpduRateLimitCount": 0},
+ },
+ },
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Interface Ethernet10 - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: 3",
+ "Interface Ethernet11 - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: 3",
+ ],
+ },
+ },
+ {
+ "name": "failure-bpdu-other-error-mismatch",
+ "test": VerifySTPCounters,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 3, "bpduRateLimitCount": 0},
"Ethernet11": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 6, "bpduRateLimitCount": 0},
},
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The following interfaces have STP BPDU packet errors: ['Ethernet10', 'Ethernet11']"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Interface Ethernet10 - STP BPDU packet other errors count mismatch - Expected: 0 Actual: 3",
+ "Interface Ethernet11 - STP BPDU packet other errors count mismatch - Expected: 0 Actual: 6",
+ ],
+ },
},
{
"name": "success",
@@ -126,7 +163,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifySTPForwardingPorts,
"eos_data": [{"unmappedVlans": [], "topologies": {}}, {"unmappedVlans": [], "topologies": {}}],
"inputs": {"vlans": [10, 20]},
- "expected": {"result": "failure", "messages": ["STP instance is not configured for the following VLAN(s): [10, 20]"]},
+ "expected": {"result": "failure", "messages": ["VLAN 10 - STP instance is not configured", "VLAN 20 - STP instance is not configured"]},
},
{
"name": "failure",
@@ -144,7 +181,10 @@ DATA: list[dict[str, Any]] = [
"inputs": {"vlans": [10, 20]},
"expected": {
"result": "failure",
- "messages": ["The following VLAN(s) have interface(s) that are not in a forwarding state: [{'VLAN 10': ['Ethernet10']}, {'VLAN 20': ['Ethernet10']}]"],
+ "messages": [
+ "VLAN 10 Interface: Ethernet10 - Invalid state - Expected: forwarding Actual: discarding",
+ "VLAN 20 Interface: Ethernet10 - Invalid state - Expected: forwarding Actual: discarding",
+ ],
},
},
{
@@ -253,6 +293,28 @@ DATA: list[dict[str, Any]] = [
"inputs": {"priority": 16384, "instances": [0]},
"expected": {"result": "success"},
},
+ {
+ "name": "success-input-instance-none",
+ "test": VerifySTPRootPriority,
+ "eos_data": [
+ {
+ "instances": {
+ "MST0": {
+ "rootBridge": {
+ "priority": 16384,
+ "systemIdExtension": 0,
+ "macAddress": "02:1c:73:8b:93:ac",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ },
+ },
+ },
+ },
+ ],
+ "inputs": {"priority": 16384},
+ "expected": {"result": "success"},
+ },
{
"name": "failure-no-instances",
"test": VerifySTPRootPriority,
@@ -273,7 +335,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"priority": 32768, "instances": [0]},
- "expected": {"result": "failure", "messages": ["Unsupported STP instance type: WRONG0"]},
+ "expected": {"result": "failure", "messages": ["STP Instance: WRONG0 - Unsupported STP instance type"]},
},
{
"name": "failure-wrong-instance-type",
@@ -282,6 +344,28 @@ DATA: list[dict[str, Any]] = [
"inputs": {"priority": 32768, "instances": [10, 20]},
"expected": {"result": "failure", "messages": ["No STP instances configured"]},
},
+ {
+ "name": "failure-instance-not-found",
+ "test": VerifySTPRootPriority,
+ "eos_data": [
+ {
+ "instances": {
+ "VL10": {
+ "rootBridge": {
+ "priority": 32768,
+ "systemIdExtension": 10,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"priority": 32768, "instances": [11, 20]},
+ "expected": {"result": "failure", "messages": ["Instance: VL11 - Not configured", "Instance: VL20 - Not configured"]},
+ },
{
"name": "failure-wrong-priority",
"test": VerifySTPRootPriority,
@@ -322,7 +406,13 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"priority": 32768, "instances": [10, 20, 30]},
- "expected": {"result": "failure", "messages": ["The following instance(s) have the wrong STP root priority configured: ['VL20', 'VL30']"]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "STP Instance: VL20 - Incorrect root priority - Expected: 32768 Actual: 8196",
+ "STP Instance: VL30 - Incorrect root priority - Expected: 32768 Actual: 8196",
+ ],
+ },
},
{
"name": "success-mstp",
@@ -462,8 +552,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The following STP topologies are not configured or number of changes not within the threshold:\n"
- "{'topologies': {'Cist': {'Cpu': {'Number of changes': 15}, 'Port-Channel5': {'Number of changes': 15}}}}"
+ "Topology: Cist Interface: Cpu - Number of changes not within the threshold - Expected: 10 Actual: 15",
+ "Topology: Cist Interface: Port-Channel5 - Number of changes not within the threshold - Expected: 10 Actual: 15",
],
},
},
@@ -484,6 +574,50 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"threshold": 10},
- "expected": {"result": "failure", "messages": ["STP is not configured."]},
+ "expected": {"result": "failure", "messages": ["STP is not configured"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySTPDisabledVlans,
+ "eos_data": [{"spanningTreeVlanInstances": {"1": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {"priority": 32768}}}, "6": {}, "4094": {}}}],
+ "inputs": {"vlans": ["6", "4094"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-stp-not-configured",
+ "test": VerifySTPDisabledVlans,
+ "eos_data": [{"spanningTreeVlanInstances": {}}],
+ "inputs": {"vlans": ["6", "4094"]},
+ "expected": {"result": "failure", "messages": ["STP is not configured"]},
+ },
+ {
+ "name": "failure-vlans-not-found",
+ "test": VerifySTPDisabledVlans,
+ "eos_data": [
+ {
+ "spanningTreeVlanInstances": {
+ "1": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {}}},
+ "6": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {}}},
+ "4094": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {}}},
+ }
+ }
+ ],
+ "inputs": {"vlans": ["16", "4093"]},
+ "expected": {"result": "failure", "messages": ["VLAN: 16 - Not configured", "VLAN: 4093 - Not configured"]},
+ },
+ {
+ "name": "failure-vlans-enabled",
+ "test": VerifySTPDisabledVlans,
+ "eos_data": [
+ {
+ "spanningTreeVlanInstances": {
+ "1": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {}}},
+ "6": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {}}},
+ "4094": {"spanningTreeVlanInstance": {"protocol": "mstp", "bridge": {}}},
+ }
+ }
+ ],
+ "inputs": {"vlans": ["6", "4094"]},
+ "expected": {"result": "failure", "messages": ["VLAN: 6 - STP is enabled", "VLAN: 4094 - STP is enabled"]},
},
]
diff --git a/tests/units/anta_tests/test_stun.py b/tests/units/anta_tests/test_stun.py
index 2383483..0d1363c 100644
--- a/tests/units/anta_tests/test_stun.py
+++ b/tests/units/anta_tests/test_stun.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.stun.py."""
@@ -108,7 +108,7 @@ DATA: list[dict[str, Any]] = [
},
"expected": {
"result": "failure",
- "messages": ["Client 100.64.3.2 Port: 4500 - STUN client translation not found.", "Client 172.18.3.2 Port: 4500 - STUN client translation not found."],
+ "messages": ["Client 100.64.3.2 Port: 4500 - STUN client translation not found", "Client 172.18.3.2 Port: 4500 - STUN client translation not found"],
},
},
{
@@ -134,7 +134,7 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Client 100.64.3.2 Port: 4500 - STUN client translation not found.",
+ "Client 100.64.3.2 Port: 4500 - STUN client translation not found",
"Client 172.18.3.2 Port: 4500 - Incorrect public-facing address - Expected: 192.118.3.2 Actual: 192.18.3.2",
"Client 172.18.3.2 Port: 4500 - Incorrect public-facing port - Expected: 6006 Actual: 4800",
],
@@ -163,7 +163,7 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Client 100.64.3.2 Port: 4500 - STUN client translation not found.",
+ "Client 100.64.3.2 Port: 4500 - STUN client translation not found",
"Client 172.18.4.2 Port: 4800 - Incorrect public-facing address - Expected: 192.118.3.2 Actual: 192.18.3.2",
"Client 172.18.4.2 Port: 4800 - Incorrect public-facing port - Expected: 6006 Actual: 4800",
],
@@ -193,7 +193,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {},
"expected": {
"result": "failure",
- "messages": ["STUN server status is disabled."],
+ "messages": ["STUN server status is disabled"],
},
},
{
@@ -208,7 +208,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {},
"expected": {
"result": "failure",
- "messages": ["STUN server is not running."],
+ "messages": ["STUN server is not running"],
},
},
{
@@ -223,7 +223,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {},
"expected": {
"result": "failure",
- "messages": ["STUN server status is disabled and not running."],
+ "messages": ["STUN server status is disabled and not running"],
},
},
]
diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py
index f610a8e..0522029 100644
--- a/tests/units/anta_tests/test_system.py
+++ b/tests/units/anta_tests/test_system.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test inputs for anta.tests.system."""
@@ -12,6 +12,7 @@ from anta.tests.system import (
VerifyCoredump,
VerifyCPUUtilization,
VerifyFileSystemUtilization,
+ VerifyMaintenance,
VerifyMemoryUtilization,
VerifyNTP,
VerifyNTPAssociations,
@@ -33,7 +34,7 @@ DATA: list[dict[str, Any]] = [
"test": VerifyUptime,
"eos_data": [{"upTime": 665.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}],
"inputs": {"minimum": 666},
- "expected": {"result": "failure", "messages": ["Device uptime is 665.15 seconds"]},
+ "expected": {"result": "failure", "messages": ["Device uptime is incorrect - Expected: 666s Actual: 665.15s"]},
},
{
"name": "success-no-reload",
@@ -74,7 +75,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Reload cause is: 'Reload after crash.'"]},
+ "expected": {"result": "failure", "messages": ["Reload cause is: Reload after crash."]},
},
{
"name": "success-without-minidump",
@@ -95,14 +96,14 @@ DATA: list[dict[str, Any]] = [
"test": VerifyCoredump,
"eos_data": [{"mode": "compressedDeferred", "coreFiles": ["core.2344.1584483862.Mlag.gz", "core.23101.1584483867.Mlag.gz"]}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Core dump(s) have been found: ['core.2344.1584483862.Mlag.gz', 'core.23101.1584483867.Mlag.gz']"]},
+ "expected": {"result": "failure", "messages": ["Core dump(s) have been found: core.2344.1584483862.Mlag.gz, core.23101.1584483867.Mlag.gz"]},
},
{
"name": "failure-with-minidump",
"test": VerifyCoredump,
"eos_data": [{"mode": "compressedDeferred", "coreFiles": ["minidump", "core.2344.1584483862.Mlag.gz", "core.23101.1584483867.Mlag.gz"]}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Core dump(s) have been found: ['core.2344.1584483862.Mlag.gz', 'core.23101.1584483867.Mlag.gz']"]},
+ "expected": {"result": "failure", "messages": ["Core dump(s) have been found: core.2344.1584483862.Mlag.gz, core.23101.1584483867.Mlag.gz"]},
},
{
"name": "success",
@@ -190,7 +191,7 @@ EntityManager::doBackoff waiting for remote sysdb version ...................ok
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization: 75.2%"]},
+ "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization - Expected: < 75% Actual: 75.2%"]},
},
{
"name": "success",
@@ -222,7 +223,7 @@ EntityManager::doBackoff waiting for remote sysdb version ...................ok
},
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Device has reported a high memory usage: 95.56%"]},
+ "expected": {"result": "failure", "messages": ["Device has reported a high memory usage - Expected: < 75% Actual: 95.56%"]},
},
{
"name": "success",
@@ -253,8 +254,8 @@ none 294M 78M 217M 84% /.overlay
"expected": {
"result": "failure",
"messages": [
- "Mount point /dev/sda2 3.9G 988M 2.9G 84% /mnt/flash is higher than 75%: reported 84%",
- "Mount point none 294M 78M 217M 84% /.overlay is higher than 75%: reported 84%",
+ "Mount point: /dev/sda2 3.9G 988M 2.9G 84% /mnt/flash - Higher disk space utilization - Expected: 75% Actual: 84%",
+ "Mount point: none 294M 78M 217M 84% /.overlay - Higher disk space utilization - Expected: 75% Actual: 84%",
],
},
},
@@ -278,7 +279,7 @@ poll interval unknown
""",
],
"inputs": None,
- "expected": {"result": "failure", "messages": ["The device is not synchronized with the configured NTP server(s): 'unsynchronised'"]},
+ "expected": {"result": "failure", "messages": ["NTP status mismatch - Expected: synchronised Actual: unsynchronised"]},
},
{
"name": "success",
@@ -346,6 +347,65 @@ poll interval unknown
},
"expected": {"result": "success"},
},
+ {
+ "name": "success-ntp-pool-as-input",
+ "test": VerifyNTPAssociations,
+ "eos_data": [
+ {
+ "peers": {
+ "1.1.1.1": {
+ "condition": "sys.peer",
+ "peerIpAddr": "1.1.1.1",
+ "stratumLevel": 1,
+ },
+ "2.2.2.2": {
+ "condition": "candidate",
+ "peerIpAddr": "2.2.2.2",
+ "stratumLevel": 2,
+ },
+ "3.3.3.3": {
+ "condition": "candidate",
+ "peerIpAddr": "3.3.3.3",
+ "stratumLevel": 2,
+ },
+ }
+ }
+ ],
+ "inputs": {"ntp_pool": {"server_addresses": ["1.1.1.1", "2.2.2.2", "3.3.3.3"], "preferred_stratum_range": [1, 2]}},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-ntp-pool-hostname",
+ "test": VerifyNTPAssociations,
+ "eos_data": [
+ {
+ "peers": {
+ "itsys-ntp010p.aristanetworks.com": {
+ "condition": "sys.peer",
+ "peerIpAddr": "1.1.1.1",
+ "stratumLevel": 1,
+ },
+ "itsys-ntp011p.aristanetworks.com": {
+ "condition": "candidate",
+ "peerIpAddr": "2.2.2.2",
+ "stratumLevel": 2,
+ },
+ "itsys-ntp012p.aristanetworks.com": {
+ "condition": "candidate",
+ "peerIpAddr": "3.3.3.3",
+ "stratumLevel": 2,
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "ntp_pool": {
+ "server_addresses": ["itsys-ntp010p.aristanetworks.com", "itsys-ntp011p.aristanetworks.com", "itsys-ntp012p.aristanetworks.com"],
+ "preferred_stratum_range": [1, 2],
+ }
+ },
+ "expected": {"result": "success"},
+ },
{
"name": "success-ip-dns",
"test": VerifyNTPAssociations,
@@ -380,7 +440,7 @@ poll interval unknown
"expected": {"result": "success"},
},
{
- "name": "failure",
+ "name": "failure-ntp-server",
"test": VerifyNTPAssociations,
"eos_data": [
{
@@ -413,9 +473,11 @@ poll interval unknown
"expected": {
"result": "failure",
"messages": [
- "1.1.1.1 (Preferred: True, Stratum: 1) - Bad association - Condition: candidate, Stratum: 2",
- "2.2.2.2 (Preferred: False, Stratum: 2) - Bad association - Condition: sys.peer, Stratum: 2",
- "3.3.3.3 (Preferred: False, Stratum: 2) - Bad association - Condition: sys.peer, Stratum: 3",
+ "NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Incorrect condition - Expected: sys.peer Actual: candidate",
+ "NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Incorrect stratum level - Expected: 1 Actual: 2",
+ "NTP Server: 2.2.2.2 Preferred: False Stratum: 2 - Incorrect condition - Expected: candidate Actual: sys.peer",
+ "NTP Server: 3.3.3.3 Preferred: False Stratum: 2 - Incorrect condition - Expected: candidate Actual: sys.peer",
+ "NTP Server: 3.3.3.3 Preferred: False Stratum: 2 - Incorrect stratum level - Expected: 2 Actual: 3",
],
},
},
@@ -463,7 +525,7 @@ poll interval unknown
},
"expected": {
"result": "failure",
- "messages": ["3.3.3.3 (Preferred: False, Stratum: 1) - Not configured"],
+ "messages": ["NTP Server: 3.3.3.3 Preferred: False Stratum: 1 - Not configured"],
},
},
{
@@ -490,9 +552,311 @@ poll interval unknown
"expected": {
"result": "failure",
"messages": [
- "1.1.1.1 (Preferred: True, Stratum: 1) - Bad association - Condition: candidate, Stratum: 1",
- "2.2.2.2 (Preferred: False, Stratum: 1) - Not configured",
- "3.3.3.3 (Preferred: False, Stratum: 1) - Not configured",
+ "NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Incorrect condition - Expected: sys.peer Actual: candidate",
+ "NTP Server: 2.2.2.2 Preferred: False Stratum: 1 - Not configured",
+ "NTP Server: 3.3.3.3 Preferred: False Stratum: 1 - Not configured",
+ ],
+ },
+ },
+ {
+ "name": "failure-ntp-pool-as-input",
+ "test": VerifyNTPAssociations,
+ "eos_data": [
+ {
+ "peers": {
+ "ntp1.pool": {
+ "condition": "sys.peer",
+ "peerIpAddr": "1.1.1.1",
+ "stratumLevel": 1,
+ },
+ "ntp2.pool": {
+ "condition": "candidate",
+ "peerIpAddr": "2.2.2.2",
+ "stratumLevel": 2,
+ },
+ "ntp3.pool": {
+ "condition": "candidate",
+ "peerIpAddr": "3.3.3.3",
+ "stratumLevel": 2,
+ },
+ }
+ }
+ ],
+ "inputs": {"ntp_pool": {"server_addresses": ["1.1.1.1", "2.2.2.2"], "preferred_stratum_range": [1, 2]}},
+ "expected": {
+ "result": "failure",
+ "messages": ["NTP Server: 3.3.3.3 Hostname: ntp3.pool - Associated but not part of the provided NTP pool"],
+ },
+ },
+ {
+ "name": "failure-ntp-pool-as-input-bad-association",
+ "test": VerifyNTPAssociations,
+ "eos_data": [
+ {
+ "peers": {
+ "ntp1.pool": {
+ "condition": "sys.peer",
+ "peerIpAddr": "1.1.1.1",
+ "stratumLevel": 1,
+ },
+ "ntp2.pool": {
+ "condition": "candidate",
+ "peerIpAddr": "2.2.2.2",
+ "stratumLevel": 2,
+ },
+ "ntp3.pool": {
+ "condition": "reject",
+ "peerIpAddr": "3.3.3.3",
+ "stratumLevel": 3,
+ },
+ }
+ }
+ ],
+ "inputs": {"ntp_pool": {"server_addresses": ["1.1.1.1", "2.2.2.2", "3.3.3.3"], "preferred_stratum_range": [1, 2]}},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "NTP Server: 3.3.3.3 Hostname: ntp3.pool - Incorrect condition - Expected: sys.peer, candidate Actual: reject",
+ "NTP Server: 3.3.3.3 Hostname: ntp3.pool - Incorrect stratum level - Expected Stratum Range: 1 to 2 Actual: 3",
+ ],
+ },
+ },
+ {
+ "name": "failure-ntp-pool-hostname",
+ "test": VerifyNTPAssociations,
+ "eos_data": [
+ {
+ "peers": {
+ "itsys-ntp010p.aristanetworks.com": {
+ "condition": "sys.peer",
+ "peerIpAddr": "1.1.1.1",
+ "stratumLevel": 5,
+ },
+ "itsys-ntp011p.aristanetworks.com": {
+ "condition": "reject",
+ "peerIpAddr": "2.2.2.2",
+ "stratumLevel": 4,
+ },
+ "itsys-ntp012p.aristanetworks.com": {
+ "condition": "candidate",
+ "peerIpAddr": "3.3.3.3",
+ "stratumLevel": 2,
+ },
+ }
+ }
+ ],
+ "inputs": {"ntp_pool": {"server_addresses": ["itsys-ntp010p.aristanetworks.com", "itsys-ntp011p.aristanetworks.com"], "preferred_stratum_range": [1, 2]}},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "NTP Server: 1.1.1.1 Hostname: itsys-ntp010p.aristanetworks.com - Incorrect stratum level - Expected Stratum Range: 1 to 2 Actual: 5",
+ "NTP Server: 2.2.2.2 Hostname: itsys-ntp011p.aristanetworks.com - Incorrect condition - Expected: sys.peer, candidate Actual: reject",
+ "NTP Server: 2.2.2.2 Hostname: itsys-ntp011p.aristanetworks.com - Incorrect stratum level - Expected Stratum Range: 1 to 2 Actual: 4",
+ "NTP Server: 3.3.3.3 Hostname: itsys-ntp012p.aristanetworks.com - Associated but not part of the provided NTP pool",
+ ],
+ },
+ },
+ {
+ "name": "success-no-maintenance-configured",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {},
+ "interfaces": {},
+ "vrfs": {},
+ "warnings": ["Maintenance Mode is disabled."],
+ },
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-maintenance-configured-but-not-enabled",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {
+ "System": {
+ "state": "active",
+ "adminState": "active",
+ "stateChangeTime": 0.0,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ }
+ },
+ "interfaces": {},
+ "vrfs": {},
+ },
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-multiple-units-but-not-enabled",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {
+ "mlag": {
+ "state": "active",
+ "adminState": "active",
+ "stateChangeTime": 0.0,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ },
+ "System": {
+ "state": "active",
+ "adminState": "active",
+ "stateChangeTime": 0.0,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ },
+ },
+ "interfaces": {},
+ "vrfs": {},
+ },
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-maintenance-enabled",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {
+ "mlag": {
+ "state": "underMaintenance",
+ "adminState": "underMaintenance",
+ "stateChangeTime": 1741257120.9532886,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ },
+ "System": {
+ "state": "active",
+ "adminState": "active",
+ "stateChangeTime": 0.0,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ },
+ },
+ "interfaces": {},
+ "vrfs": {},
+ },
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Units under maintenance: 'mlag'.",
+ "Possible causes: 'Quiesce is configured'.",
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-reasons",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {
+ "mlag": {
+ "state": "underMaintenance",
+ "adminState": "underMaintenance",
+ "stateChangeTime": 1741257120.9532895,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ },
+ "System": {
+ "state": "maintenanceModeEnter",
+ "adminState": "underMaintenance",
+ "stateChangeTime": 1741257669.7231765,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ },
+ },
+ "interfaces": {},
+ "vrfs": {},
+ },
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Units under maintenance: 'mlag'.",
+ "Units entering maintenance: 'System'.",
+ "Possible causes: 'Quiesce is configured'.",
+ ],
+ },
+ },
+ {
+ "name": "failure-onboot-maintenance",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {
+ "System": {
+ "state": "underMaintenance",
+ "adminState": "underMaintenance",
+ "stateChangeTime": 1741258774.3756502,
+ "onBootMaintenance": True,
+ "intfsViolatingTrafficThreshold": False,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ }
+ },
+ "interfaces": {},
+ "vrfs": {},
+ },
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Units under maintenance: 'System'.",
+ "Possible causes: 'On-boot maintenance is configured, Quiesce is configured'.",
+ ],
+ },
+ },
+ {
+ "name": "failure-entering-maintenance-interface-violation",
+ "test": VerifyMaintenance,
+ "eos_data": [
+ {
+ "units": {
+ "System": {
+ "state": "maintenanceModeEnter",
+ "adminState": "underMaintenance",
+ "stateChangeTime": 1741257669.7231765,
+ "onBootMaintenance": False,
+ "intfsViolatingTrafficThreshold": True,
+ "aggInBpsRate": 0,
+ "aggOutBpsRate": 0,
+ }
+ },
+ "interfaces": {},
+ "vrfs": {},
+ },
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Units entering maintenance: 'System'.",
+ "Possible causes: 'Interface traffic threshold violation, Quiesce is configured'.",
],
},
},
diff --git a/tests/units/anta_tests/test_vlan.py b/tests/units/anta_tests/test_vlan.py
index 6bbfac4..fd900ca 100644
--- a/tests/units/anta_tests/test_vlan.py
+++ b/tests/units/anta_tests/test_vlan.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.vlan.py."""
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import Any
-from anta.tests.vlan import VerifyVlanInternalPolicy
+from anta.tests.vlan import VerifyDynamicVlanSource, VerifyVlanInternalPolicy
from tests.units.anta_tests import test
DATA: list[dict[str, Any]] = [
@@ -23,14 +23,70 @@ DATA: list[dict[str, Any]] = [
"test": VerifyVlanInternalPolicy,
"eos_data": [{"policy": "descending", "startVlanId": 4094, "endVlanId": 1006}],
"inputs": {"policy": "ascending", "start_vlan_id": 1006, "end_vlan_id": 4094},
+ "expected": {
+ "result": "failure",
+ "messages": ["Incorrect VLAN internal allocation policy configured - Expected: ascending Actual: descending"],
+ },
+ },
+ {
+ "name": "failure-incorrect-start-end-id",
+ "test": VerifyVlanInternalPolicy,
+ "eos_data": [{"policy": "ascending", "startVlanId": 4094, "endVlanId": 1006}],
+ "inputs": {"policy": "ascending", "start_vlan_id": 1006, "end_vlan_id": 4094},
"expected": {
"result": "failure",
"messages": [
- "The VLAN internal allocation policy is not configured properly:\n"
- "Expected `ascending` as the policy, but found `descending` instead.\n"
- "Expected `1006` as the startVlanId, but found `4094` instead.\n"
- "Expected `4094` as the endVlanId, but found `1006` instead."
+ "VLAN internal allocation policy: ascending - Incorrect start VLAN id configured - Expected: 1006 Actual: 4094",
+ "VLAN internal allocation policy: ascending - Incorrect end VLAN id configured - Expected: 4094 Actual: 1006",
],
},
},
+ {
+ "name": "success",
+ "test": VerifyDynamicVlanSource,
+ "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": [1401]}, "vccbfd": {"vlanIds": [1501]}}}],
+ "inputs": {"sources": ["evpn", "mlagsync"], "strict": False},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-dynamic-vlan-sources",
+ "test": VerifyDynamicVlanSource,
+ "eos_data": [{"dynamicVlans": {}}],
+ "inputs": {"sources": ["evpn", "mlagsync"], "strict": False},
+ "expected": {"result": "failure", "messages": ["Dynamic VLAN source(s) not found in configuration: evpn, mlagsync"]},
+ },
+ {
+ "name": "failure-dynamic-vlan-sources-mismatch",
+ "test": VerifyDynamicVlanSource,
+ "eos_data": [{"dynamicVlans": {"vccbfd": {"vlanIds": [1500]}, "mlagsync": {"vlanIds": [1501]}}}],
+ "inputs": {"sources": ["evpn", "mlagsync"], "strict": False},
+ "expected": {
+ "result": "failure",
+ "messages": ["Dynamic VLAN source(s) not found in configuration: evpn"],
+ },
+ },
+ {
+ "name": "success-strict-mode",
+ "test": VerifyDynamicVlanSource,
+ "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": [1502], "vccbfd": {"vlanIds": []}}}}],
+ "inputs": {"sources": ["evpn", "mlagsync"], "strict": True},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-all-sources-exact-match-additional-source-found",
+ "test": VerifyDynamicVlanSource,
+ "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": [1500]}, "vccbfd": {"vlanIds": [1500]}}}],
+ "inputs": {"sources": ["evpn", "mlagsync"], "strict": True},
+ "expected": {
+ "result": "failure",
+ "messages": ["Strict mode enabled: Unexpected sources have VLANs allocated: vccbfd"],
+ },
+ },
+ {
+ "name": "failure-all-sources-exact-match-expected-source-not-found",
+ "test": VerifyDynamicVlanSource,
+ "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": []}}}],
+ "inputs": {"sources": ["evpn", "mlagsync"], "strict": True},
+ "expected": {"result": "failure", "messages": ["Dynamic VLAN source(s) exist but have no VLANs allocated: mlagsync"]},
+ },
]
diff --git a/tests/units/anta_tests/test_vxlan.py b/tests/units/anta_tests/test_vxlan.py
index 4278a59..ef8a294 100644
--- a/tests/units/anta_tests/test_vxlan.py
+++ b/tests/units/anta_tests/test_vxlan.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.vxlan.py."""
@@ -23,28 +23,28 @@ DATA: list[dict[str, Any]] = [
"test": VerifyVxlan1Interface,
"eos_data": [{"interfaceDescriptions": {"Loopback0": {"lineProtocolStatus": "up", "interfaceStatus": "up"}}}],
"inputs": None,
- "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]},
+ "expected": {"result": "skipped", "messages": ["Interface: Vxlan1 - Not configured"]},
},
{
"name": "failure-down-up",
"test": VerifyVxlan1Interface,
"eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "up"}}}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/up"]},
+ "expected": {"result": "failure", "messages": ["Interface: Vxlan1 - Incorrect Line protocol status/Status - Expected: up/up Actual: down/up"]},
},
{
"name": "failure-up-down",
"test": VerifyVxlan1Interface,
"eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "up", "interfaceStatus": "down"}}}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Vxlan1 interface is up/down"]},
+ "expected": {"result": "failure", "messages": ["Interface: Vxlan1 - Incorrect Line protocol status/Status - Expected: up/up Actual: up/down"]},
},
{
"name": "failure-down-down",
"test": VerifyVxlan1Interface,
"eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "down"}}}],
"inputs": None,
- "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/down"]},
+ "expected": {"result": "failure", "messages": ["Interface: Vxlan1 - Incorrect Line protocol status/Status - Expected: up/up Actual: down/down"]},
},
{
"name": "success",
@@ -176,15 +176,7 @@ DATA: list[dict[str, Any]] = [
"inputs": None,
"expected": {
"result": "failure",
- "messages": [
- "VXLAN config sanity check is not passing: {'localVtep': {'description': 'Local VTEP Configuration Check', "
- "'allCheckPass': False, 'detail': '', 'hasWarning': True, 'items': [{'name': 'Loopback IP Address', 'checkPass': True, "
- "'hasWarning': False, 'detail': ''}, {'name': 'VLAN-VNI Map', 'checkPass': False, 'hasWarning': False, 'detail': "
- "'No VLAN-VNI mapping in Vxlan1'}, {'name': 'Flood List', 'checkPass': False, 'hasWarning': True, 'detail': "
- "'No VXLAN VLANs in Vxlan1'}, {'name': 'Routing', 'checkPass': True, 'hasWarning': False, 'detail': ''}, {'name': "
- "'VNI VRF ACL', 'checkPass': True, 'hasWarning': False, 'detail': ''}, {'name': 'VRF-VNI Dynamic VLAN', 'checkPass': True, "
- "'hasWarning': False, 'detail': ''}, {'name': 'Decap VRF-VNI Map', 'checkPass': True, 'hasWarning': False, 'detail': ''}]}}",
- ],
+ "messages": ["Vxlan Category: localVtep - Config sanity check is not passing"],
},
},
{
@@ -228,7 +220,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}},
- "expected": {"result": "failure", "messages": ["The following VNI(s) have no binding: ['10010']"]},
+ "expected": {"result": "failure", "messages": ["Interface: Vxlan1 VNI: 10010 - Binding not found"]},
},
{
"name": "failure-wrong-binding",
@@ -246,7 +238,7 @@ DATA: list[dict[str, Any]] = [
},
],
"inputs": {"bindings": {10020: 20, 500: 1199}},
- "expected": {"result": "failure", "messages": ["The following VNI(s) have the wrong VLAN binding: [{'10020': 30}]"]},
+ "expected": {"result": "failure", "messages": ["Interface: Vxlan1 VNI: 10020 VLAN: 20 - Wrong VLAN binding - Actual: 30"]},
},
{
"name": "failure-no-and-wrong-binding",
@@ -266,7 +258,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}},
"expected": {
"result": "failure",
- "messages": ["The following VNI(s) have no binding: ['10010']", "The following VNI(s) have the wrong VLAN binding: [{'10020': 30}]"],
+ "messages": ["Interface: Vxlan1 VNI: 10010 - Binding not found", "Interface: Vxlan1 VNI: 10020 VLAN: 20 - Wrong VLAN binding - Actual: 30"],
},
},
{
@@ -288,21 +280,21 @@ DATA: list[dict[str, Any]] = [
"test": VerifyVxlanVtep,
"eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.5", "10.1.1.6"]}}}],
"inputs": {"vteps": ["10.1.1.5", "10.1.1.6", "10.1.1.7"]},
- "expected": {"result": "failure", "messages": ["The following VTEP peer(s) are missing from the Vxlan1 interface: ['10.1.1.7']"]},
+ "expected": {"result": "failure", "messages": ["The following VTEP peer(s) are missing from the Vxlan1 interface: 10.1.1.7"]},
},
{
"name": "failure-no-vtep",
"test": VerifyVxlanVtep,
"eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": []}}}],
"inputs": {"vteps": ["10.1.1.5", "10.1.1.6"]},
- "expected": {"result": "failure", "messages": ["The following VTEP peer(s) are missing from the Vxlan1 interface: ['10.1.1.5', '10.1.1.6']"]},
+ "expected": {"result": "failure", "messages": ["The following VTEP peer(s) are missing from the Vxlan1 interface: 10.1.1.5, 10.1.1.6"]},
},
{
"name": "failure-no-input-vtep",
"test": VerifyVxlanVtep,
"eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.5"]}}}],
"inputs": {"vteps": []},
- "expected": {"result": "failure", "messages": ["Unexpected VTEP peer(s) on Vxlan1 interface: ['10.1.1.5']"]},
+ "expected": {"result": "failure", "messages": ["Unexpected VTEP peer(s) on Vxlan1 interface: 10.1.1.5"]},
},
{
"name": "failure-missmatch",
@@ -312,8 +304,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "The following VTEP peer(s) are missing from the Vxlan1 interface: ['10.1.1.5']",
- "Unexpected VTEP peer(s) on Vxlan1 interface: ['10.1.1.7', '10.1.1.8']",
+ "The following VTEP peer(s) are missing from the Vxlan1 interface: 10.1.1.5",
+ "Unexpected VTEP peer(s) on Vxlan1 interface: 10.1.1.7, 10.1.1.8",
],
},
},
@@ -345,7 +337,7 @@ DATA: list[dict[str, Any]] = [
"inputs": {"source_interface": "lo1", "udp_port": 4789},
"expected": {
"result": "failure",
- "messages": ["Source interface is not correct. Expected `Loopback1` as source interface but found `Loopback10` instead."],
+ "messages": ["Interface: Vxlan1 - Incorrect Source interface - Expected: Loopback1 Actual: Loopback10"],
},
},
{
@@ -356,8 +348,8 @@ DATA: list[dict[str, Any]] = [
"expected": {
"result": "failure",
"messages": [
- "Source interface is not correct. Expected `Loopback1` as source interface but found `Loopback10` instead.",
- "UDP port is not correct. Expected `4780` as UDP port but found `4789` instead.",
+ "Interface: Vxlan1 - Incorrect Source interface - Expected: Loopback1 Actual: Loopback10",
+ "Interface: Vxlan1 - Incorrect UDP port - Expected: 4780 Actual: 4789",
],
},
},
diff --git a/tests/units/asynceapi/__init__.py b/tests/units/asynceapi/__init__.py
index d4282a3..bd6e96d 100644
--- a/tests/units/asynceapi/__init__.py
+++ b/tests/units/asynceapi/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Unit tests for the asynceapi client package used by ANTA."""
diff --git a/tests/units/asynceapi/conftest.py b/tests/units/asynceapi/conftest.py
index 812d5b9..d5ab502 100644
--- a/tests/units/asynceapi/conftest.py
+++ b/tests/units/asynceapi/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Fixtures for the asynceapi client package."""
diff --git a/tests/units/asynceapi/test__constants.py b/tests/units/asynceapi/test__constants.py
new file mode 100644
index 0000000..aee1a48
--- /dev/null
+++ b/tests/units/asynceapi/test__constants.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2023-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Unit tests for the asynceapi._constants module."""
+
+import pytest
+
+from asynceapi._constants import EapiCommandFormat
+
+
+class TestEapiCommandFormat:
+ """Test cases for the EapiCommandFormat enum."""
+
+ def test_enum_values(self) -> None:
+ """Test that the enum has the expected values."""
+ assert EapiCommandFormat.JSON.value == "json"
+ assert EapiCommandFormat.TEXT.value == "text"
+
+ def test_str_method(self) -> None:
+ """Test that the __str__ method returns the string value."""
+ assert str(EapiCommandFormat.JSON) == "json"
+ assert str(EapiCommandFormat.TEXT) == "text"
+
+ # Test in string formatting
+ assert f"Format: {EapiCommandFormat.JSON}" == "Format: json"
+
+ def test_string_behavior(self) -> None:
+ """Test that the enum behaves like a string."""
+ # String methods should work
+ assert EapiCommandFormat.JSON.upper() == "JSON"
+
+ # String comparisons should work
+ assert EapiCommandFormat.JSON == "json"
+ assert EapiCommandFormat.TEXT == "text"
+
+ def test_enum_lookup(self) -> None:
+ """Test enum lookup by value."""
+ assert EapiCommandFormat("json") is EapiCommandFormat.JSON
+ assert EapiCommandFormat("text") is EapiCommandFormat.TEXT
+
+ with pytest.raises(ValueError, match="'invalid' is not a valid EapiCommandFormat"):
+ EapiCommandFormat("invalid")
diff --git a/tests/units/asynceapi/test__models.py b/tests/units/asynceapi/test__models.py
new file mode 100644
index 0000000..447c243
--- /dev/null
+++ b/tests/units/asynceapi/test__models.py
@@ -0,0 +1,435 @@
+# Copyright (c) 2023-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Unit tests for the asynceapi._models module."""
+
+import logging
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+import pytest
+
+from asynceapi._constants import EapiCommandFormat
+from asynceapi._errors import EapiReponseError
+from asynceapi._models import EapiCommandResult, EapiRequest, EapiResponse
+
+if TYPE_CHECKING:
+ from asynceapi._types import EapiComplexCommand, EapiSimpleCommand
+
+
+class TestEapiRequest:
+ """Test cases for the EapiRequest class."""
+
+ def test_init_with_defaults(self) -> None:
+ """Test initialization with just required parameters."""
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = ["show version", "show interfaces"]
+ req = EapiRequest(commands=commands)
+
+ # Check required attributes
+ assert req.commands == commands
+
+ # Check default values
+ assert req.version == "latest"
+ assert req.format == EapiCommandFormat.JSON
+ assert req.timestamps is False
+ assert req.auto_complete is False
+ assert req.expand_aliases is False
+ assert req.stop_on_error is True
+
+ # Check that ID is generated as a UUID hex string
+ try:
+ UUID(str(req.id))
+ is_valid_uuid = True
+ except ValueError:
+ is_valid_uuid = False
+ assert is_valid_uuid
+
+ def test_init_with_custom_values(self) -> None:
+ """Test initialization with custom parameter values."""
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = [{"cmd": "enable", "input": "password"}, "show version"]
+ req = EapiRequest(
+ commands=commands,
+ version=1,
+ format=EapiCommandFormat.TEXT,
+ timestamps=True,
+ auto_complete=True,
+ expand_aliases=True,
+ stop_on_error=False,
+ id="custom-id-123",
+ )
+
+ # Check all attributes match expected values
+ assert req.commands == commands
+ assert req.version == 1
+ assert req.format == EapiCommandFormat.TEXT
+ assert req.timestamps is True
+ assert req.auto_complete is True
+ assert req.expand_aliases is True
+ assert req.stop_on_error is False
+ assert req.id == "custom-id-123"
+
+ def test_to_jsonrpc(self) -> None:
+ """Test conversion to JSON-RPC dictionary."""
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = ["show version", "show interfaces"]
+ req = EapiRequest(commands=commands, version=1, format=EapiCommandFormat.TEXT, id="test-id-456")
+
+ jsonrpc = req.to_jsonrpc()
+
+ # Check that structure matches expected JSON-RPC format
+ assert jsonrpc["jsonrpc"] == "2.0"
+ assert jsonrpc["method"] == "runCmds"
+ assert jsonrpc["id"] == "test-id-456"
+
+ # Check params matches our configuration
+ params = jsonrpc["params"]
+ assert params["version"] == 1
+ assert params["cmds"] == commands
+ assert params["format"] == EapiCommandFormat.TEXT
+ assert params["timestamps"] is False
+ assert params["autoComplete"] is False
+ assert params["expandAliases"] is False
+ assert params["stopOnError"] is True
+
+ def test_to_jsonrpc_with_complex_commands(self) -> None:
+ """Test JSON-RPC conversion with complex commands."""
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = [
+ {"cmd": "enable", "input": "password"},
+ {"cmd": "configure", "input": ""},
+ {"cmd": "hostname test-device"},
+ ]
+ req = EapiRequest(commands=commands)
+
+ jsonrpc = req.to_jsonrpc()
+
+ # Verify commands are passed correctly
+ assert jsonrpc["params"]["cmds"] == commands
+
+ def test_immutability(self) -> None:
+ """Test that the dataclass is truly immutable (frozen)."""
+ commands: list[EapiSimpleCommand | EapiComplexCommand] = ["show version"]
+ req = EapiRequest(commands=commands)
+
+ # Attempting to modify any attribute should raise an error
+ with pytest.raises(AttributeError):
+ req.commands = ["new command"] # type: ignore[misc]
+
+ with pytest.raises(AttributeError):
+ req.id = "new-id" # type: ignore[misc]
+
+
+class TestEapiResponse:
+ """Test cases for the EapiResponse class."""
+
+ def test_init_and_properties(self) -> None:
+ """Test basic initialization and properties."""
+ # Create mock command results
+ result1 = EapiCommandResult(command="show version", output={"version": "4.33.2F-40713977.4332F (engineering build)"})
+ result2 = EapiCommandResult(command="show hostname", output={"hostname": "DC1-LEAF1A"})
+
+ # Create response with results
+ results = {0: result1, 1: result2}
+ response = EapiResponse(request_id="test-123", _results=results)
+
+ # Check attributes
+ assert response.request_id == "test-123"
+ assert response.error_code is None
+ assert response.error_message is None
+
+ # Check properties
+ assert response.success is True
+ assert len(response.results) == 2
+ assert response.results[0] == result1
+ assert response.results[1] == result2
+
+ def test_error_response(self) -> None:
+ """Test initialization with error information."""
+ result = EapiCommandResult(command="show bad command", output=None, errors=["Invalid input (at token 1: 'bad')"], success=False)
+ results = {0: result}
+ response = EapiResponse(
+ request_id="test-456", _results=results, error_code=1002, error_message="CLI command 1 of 1 'show bad command' failed: invalid command"
+ )
+
+ assert response.request_id == "test-456"
+ assert response.error_code == 1002
+ assert response.error_message == "CLI command 1 of 1 'show bad command' failed: invalid command"
+ assert response.success is False
+ assert len(response.results) == 1
+ assert response.results[0].success is False
+ assert "Invalid input (at token 1: 'bad')" in response.results[0].errors
+
+ def test_len_and_iteration(self) -> None:
+ """Test __len__ and __iter__ methods."""
+ # Create 3 command results
+ results = {
+ 0: EapiCommandResult(command="cmd1", output="out1"),
+ 1: EapiCommandResult(command="cmd2", output="out2"),
+ 2: EapiCommandResult(command="cmd3", output="out3"),
+ }
+ response = EapiResponse(request_id="test-789", _results=results)
+
+ # Test __len__
+ assert len(response) == 3
+
+ # Test __iter__
+ iterated_results = list(response)
+ assert len(iterated_results) == 3
+ assert [r.command for r in iterated_results] == ["cmd1", "cmd2", "cmd3"]
+
+ def test_from_jsonrpc_success(self) -> None:
+ """Test from_jsonrpc with successful response."""
+ # Mock request
+ request = EapiRequest(commands=["show version", "show hostname"], format=EapiCommandFormat.JSON)
+
+ # Mock response data
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "test-id-123",
+ "result": [{"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}, {"hostname": "DC1-LEAF1A"}],
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify response object
+ assert response.request_id == "test-id-123"
+ assert response.success is True
+ assert response.error_code is None
+ assert response.error_message is None
+
+ # Verify results
+ assert len(response) == 2
+ assert response.results[0].command == "show version"
+ assert response.results[0].output == {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}
+ assert response.results[0].success is True
+ assert response.results[1].command == "show hostname"
+ assert response.results[1].output == {"hostname": "DC1-LEAF1A"}
+ assert response.results[1].success is True
+
+ def test_from_jsonrpc_text_format(self) -> None:
+ """Test from_jsonrpc with TEXT format responses."""
+ # Mock request with TEXT format
+ request = EapiRequest(commands=["show version", "show hostname"], format=EapiCommandFormat.TEXT)
+
+ # Mock response data
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "text-format-id",
+ "result": [{"output": "Arista cEOSLab\n\nSoftware image version: 4.33.2F-40713977.4332F"}, {"output": "Hostname: DC1-LEAF1A\nFQDN: DC1-LEAF1A\n"}],
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify results contain the text output
+ assert len(response) == 2
+ assert response.results[0].output is not None
+ assert "Arista cEOSLab" in response.results[0].output
+ assert response.results[1].output is not None
+ assert "Hostname: DC1-LEAF1A" in response.results[1].output
+
+ def test_from_jsonrpc_with_timestamps(self) -> None:
+ """Test from_jsonrpc with timestamps enabled."""
+ # Mock request with timestamps
+ request = EapiRequest(commands=["show version"], format=EapiCommandFormat.JSON, timestamps=True)
+
+ # Mock response data with timestamps
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "timestamp-id",
+ "result": [
+ {
+ "modelName": "cEOSLab",
+ "version": "4.33.2F-40713977.4332F (engineering build)",
+ "_meta": {"execStartTime": 1741014072.2534037, "execDuration": 0.0024061203002929688},
+ }
+ ],
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify timestamp data is processed
+ assert len(response) == 1
+ assert response.results[0].start_time == 1741014072.2534037
+ assert response.results[0].duration == 0.0024061203002929688
+
+ # Verify _meta is removed from output
+ assert response.results[0].output is not None
+ assert "_meta" not in response.results[0].output
+
+ def test_from_jsonrpc_error_stop_on_error_true(self) -> None:
+ """Test from_jsonrpc with error and stop_on_error=True."""
+ # Mock request with stop_on_error=True
+ request = EapiRequest(commands=["show bad command", "show version", "show hostname"], stop_on_error=True)
+
+ # Mock error response
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "error-id",
+ "error": {
+ "code": 1002,
+ "message": "CLI command 1 of 3 'show bad command' failed: invalid command",
+ "data": [{"errors": ["Invalid input (at token 1: 'bad')"]}],
+ },
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify error info
+ assert response.request_id == "error-id"
+ assert response.error_code == 1002
+ assert response.error_message == "CLI command 1 of 3 'show bad command' failed: invalid command"
+ assert response.success is False
+
+ # Verify results - should have entries for all commands
+ assert len(response) == 3
+
+ # First command failed
+ assert response.results[0].command == "show bad command"
+ assert response.results[0].output is None
+ assert response.results[0].success is False
+ assert response.results[0].errors == ["Invalid input (at token 1: 'bad')"]
+
+ # Remaining commands weren't executed due to stop_on_error=True
+ assert response.results[1].command == "show version"
+ assert response.results[1].output is None
+ assert response.results[1].success is False
+ assert "Command not executed due to previous error" in response.results[1].errors
+ assert response.results[1].executed is False
+
+ assert response.results[2].command == "show hostname"
+ assert response.results[2].output is None
+ assert response.results[2].success is False
+ assert "Command not executed due to previous error" in response.results[2].errors
+ assert response.results[2].executed is False
+
+ def test_from_jsonrpc_error_stop_on_error_false(self) -> None:
+ """Test from_jsonrpc with error and stop_on_error=False."""
+ # Mock request with stop_on_error=False
+ request = EapiRequest(commands=["show bad command", "show version", "show hostname"], stop_on_error=False)
+
+ # Mock response with error for first command but others succeed
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "error-continue-id",
+ "error": {
+ "code": 1002,
+ "message": "CLI command 1 of 3 'show bad command' failed: invalid command",
+ "data": [
+ {"errors": ["Invalid input (at token 1: 'bad')"]},
+ {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"},
+ {"hostname": "DC1-LEAF1A"},
+ ],
+ },
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify error info
+ assert response.request_id == "error-continue-id"
+ assert response.error_code == 1002
+ assert response.error_message == "CLI command 1 of 3 'show bad command' failed: invalid command"
+ assert response.success is False
+
+ # Verify individual command results
+ assert len(response) == 3
+
+ # First command failed
+ assert response.results[0].command == "show bad command"
+ assert response.results[0].output is None
+ assert response.results[0].success is False
+ assert response.results[0].errors == ["Invalid input (at token 1: 'bad')"]
+
+ # Remaining commands succeeded
+ assert response.results[1].command == "show version"
+ assert response.results[1].output == {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}
+ assert response.results[1].success is True
+
+ assert response.results[2].command == "show hostname"
+ assert response.results[2].output == {"hostname": "DC1-LEAF1A"}
+ assert response.results[2].success is True
+
+ def test_from_jsonrpc_raise_on_error(self) -> None:
+ """Test from_jsonrpc with raise_on_error=True."""
+ # Mock request
+ request = EapiRequest(commands=["show bad command"])
+
+ # Mock error response
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "raise-error-id",
+ "error": {
+ "code": 1002,
+ "message": "CLI command 1 of 1 'show bad command' failed: invalid command",
+ "data": [{"errors": ["Invalid input (at token 1: 'bad')"]}],
+ },
+ }
+
+ # Should raise EapiReponseError
+ with pytest.raises(EapiReponseError) as excinfo:
+ EapiResponse.from_jsonrpc(jsonrpc_response, request, raise_on_error=True)
+
+ # Verify the exception contains the response
+ assert excinfo.value.response.request_id == "raise-error-id"
+ assert excinfo.value.response.error_code == 1002
+ assert excinfo.value.response.error_message == "CLI command 1 of 1 'show bad command' failed: invalid command"
+
+ def test_from_jsonrpc_string_data(self, caplog: pytest.LogCaptureFixture) -> None:
+ """Test from_jsonrpc with string data response."""
+ caplog.set_level(logging.WARNING)
+
+ # Mock request
+ request = EapiRequest(commands=["show bgp ipv4 unicast summary", "show bad command"])
+
+ # Mock response with JSON string
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "EapiExplorer-1",
+ "error": {
+ "code": 1002,
+ "message": "CLI command 2 of 2 'show bad command' failed: invalid command",
+ "data": [
+ '{"vrfs":{"default":{"vrf":"default","routerId":"10.1.0.11","asn":"65101","peers":{}}}}\n',
+ {"errors": ["Invalid input (at token 1: 'bad')"]},
+ ],
+ },
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify string was parsed as JSON
+ assert response.results[0].output == {"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.11", "asn": "65101", "peers": {}}}}
+
+ # Now test with a non-JSON string
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "EapiExplorer-1",
+ "error": {
+ "code": 1002,
+ "message": "CLI command 2 of 2 'show bad command' failed: invalid command",
+ "data": ["This is not JSON", {"errors": ["Invalid input (at token 1: 'bad')"]}],
+ },
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify WARNING log message
+ assert "Invalid JSON response for command: show bgp ipv4 unicast summary. Storing as text: This is not JSON" in caplog.text
+
+ # Verify string is kept as is
+ assert response.results[0].output == "This is not JSON"
+
+ def test_from_jsonrpc_complex_commands(self) -> None:
+ """Test from_jsonrpc with complex command structures."""
+ # Mock request with complex commands
+ request = EapiRequest(commands=[{"cmd": "enable", "input": "password"}, "show version"])
+
+ # Mock response
+ jsonrpc_response = {
+ "jsonrpc": "2.0",
+ "id": "complex-cmd-id",
+ "result": [{}, {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}],
+ }
+
+ response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
+
+ # Verify command strings are extracted correctly
+ assert response.results[0].command == "enable"
+ assert response.results[1].command == "show version"
diff --git a/tests/units/asynceapi/test_data.py b/tests/units/asynceapi/test_data.py
index 908d608..94004c1 100644
--- a/tests/units/asynceapi/test_data.py
+++ b/tests/units/asynceapi/test_data.py
@@ -1,9 +1,12 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Unit tests data for the asynceapi client package."""
-SUCCESS_EAPI_RESPONSE = {
+from asynceapi._constants import EapiCommandFormat
+from asynceapi._types import EapiJsonOutput, JsonRpc
+
+SUCCESS_EAPI_RESPONSE: EapiJsonOutput = {
"jsonrpc": "2.0",
"id": "EapiExplorer-1",
"result": [
@@ -49,7 +52,7 @@ SUCCESS_EAPI_RESPONSE = {
}
"""Successful eAPI JSON response."""
-ERROR_EAPI_RESPONSE = {
+ERROR_EAPI_RESPONSE: EapiJsonOutput = {
"jsonrpc": "2.0",
"id": "EapiExplorer-1",
"error": {
@@ -84,5 +87,10 @@ ERROR_EAPI_RESPONSE = {
}
"""Error eAPI JSON response."""
-JSONRPC_REQUEST_TEMPLATE = {"jsonrpc": "2.0", "method": "runCmds", "params": {"version": 1, "cmds": [], "format": "json"}, "id": "EapiExplorer-1"}
+JSONRPC_REQUEST_TEMPLATE: JsonRpc = {
+ "jsonrpc": "2.0",
+ "method": "runCmds",
+ "params": {"version": 1, "cmds": [], "format": EapiCommandFormat.JSON},
+ "id": "EapiExplorer-1",
+}
"""Template for JSON-RPC eAPI request. `cmds` must be filled by the parametrize decorator."""
diff --git a/tests/units/asynceapi/test_device.py b/tests/units/asynceapi/test_device.py
index 2c6375a..c15eb35 100644
--- a/tests/units/asynceapi/test_device.py
+++ b/tests/units/asynceapi/test_device.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
-"""Unit tests the asynceapi.device module."""
+"""Unit tests for the asynceapi.device module."""
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
import pytest
from httpx import HTTPStatusError
@@ -17,6 +17,8 @@ from .test_data import ERROR_EAPI_RESPONSE, JSONRPC_REQUEST_TEMPLATE, SUCCESS_EA
if TYPE_CHECKING:
from pytest_httpx import HTTPXMock
+ from asynceapi._types import EapiComplexCommand, EapiSimpleCommand
+
@pytest.mark.parametrize(
"cmds",
@@ -30,10 +32,10 @@ if TYPE_CHECKING:
async def test_jsonrpc_exec_success(
asynceapi_device: Device,
httpx_mock: HTTPXMock,
- cmds: list[str | dict[str, Any]],
+ cmds: list[EapiSimpleCommand | EapiComplexCommand],
) -> None:
"""Test the Device.jsonrpc_exec method with a successful response. Simple and complex commands are tested."""
- jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy()
+ jsonrpc_request = JSONRPC_REQUEST_TEMPLATE.copy()
jsonrpc_request["params"]["cmds"] = cmds
httpx_mock.add_response(json=SUCCESS_EAPI_RESPONSE)
@@ -55,19 +57,18 @@ async def test_jsonrpc_exec_success(
async def test_jsonrpc_exec_eapi_command_error(
asynceapi_device: Device,
httpx_mock: HTTPXMock,
- cmds: list[str | dict[str, Any]],
+ cmds: list[EapiSimpleCommand | EapiComplexCommand],
) -> None:
"""Test the Device.jsonrpc_exec method with an error response. Simple and complex commands are tested."""
- jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy()
+ jsonrpc_request = JSONRPC_REQUEST_TEMPLATE.copy()
jsonrpc_request["params"]["cmds"] = cmds
- error_eapi_response: dict[str, Any] = ERROR_EAPI_RESPONSE.copy()
- httpx_mock.add_response(json=error_eapi_response)
+ httpx_mock.add_response(json=ERROR_EAPI_RESPONSE)
with pytest.raises(EapiCommandError) as exc_info:
await asynceapi_device.jsonrpc_exec(jsonrpc=jsonrpc_request)
- assert exc_info.value.passed == [error_eapi_response["error"]["data"][0]]
+ assert exc_info.value.passed == [ERROR_EAPI_RESPONSE["error"]["data"][0]]
assert exc_info.value.failed == "bad command"
assert exc_info.value.errors == ["Invalid input (at token 1: 'bad')"]
assert exc_info.value.errmsg == "CLI command 2 of 3 'bad command' failed: invalid command"
@@ -76,7 +77,7 @@ async def test_jsonrpc_exec_eapi_command_error(
async def test_jsonrpc_exec_http_status_error(asynceapi_device: Device, httpx_mock: HTTPXMock) -> None:
"""Test the Device.jsonrpc_exec method with an HTTPStatusError."""
- jsonrpc_request: dict[str, Any] = JSONRPC_REQUEST_TEMPLATE.copy()
+ jsonrpc_request = JSONRPC_REQUEST_TEMPLATE.copy()
jsonrpc_request["params"]["cmds"] = ["show version"]
httpx_mock.add_response(status_code=500, text="Internal Server Error")
diff --git a/tests/units/cli/__init__.py b/tests/units/cli/__init__.py
index 1d4cf6c..bdbfaf2 100644
--- a/tests/units/cli/__init__.py
+++ b/tests/units/cli/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.cli submodule."""
diff --git a/tests/units/cli/check/__init__.py b/tests/units/cli/check/__init__.py
index a116af4..81c2436 100644
--- a/tests/units/cli/check/__init__.py
+++ b/tests/units/cli/check/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.cli.check submodule."""
diff --git a/tests/units/cli/check/test__init__.py b/tests/units/cli/check/test__init__.py
index 2501dc8..94fd49e 100644
--- a/tests/units/cli/check/test__init__.py
+++ b/tests/units/cli/check/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.check."""
diff --git a/tests/units/cli/check/test_commands.py b/tests/units/cli/check/test_commands.py
index 11c2b5f..abf76b8 100644
--- a/tests/units/cli/check/test_commands.py
+++ b/tests/units/cli/check/test_commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.check.commands."""
diff --git a/tests/units/cli/conftest.py b/tests/units/cli/conftest.py
index 71c23e9..3764695 100644
--- a/tests/units/cli/conftest.py
+++ b/tests/units/cli/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files."""
@@ -39,7 +39,7 @@ MOCK_CLI_JSON: dict[str, asynceapi.EapiCommandError | dict[str, Any]] = {
errmsg="Invalid command",
not_exec=[],
),
- "show interfaces": {},
+ "show interfaces": {"interfaces": {}},
}
MOCK_CLI_TEXT: dict[str, asynceapi.EapiCommandError | str] = {
diff --git a/tests/units/cli/debug/__init__.py b/tests/units/cli/debug/__init__.py
index ccce49c..129b162 100644
--- a/tests/units/cli/debug/__init__.py
+++ b/tests/units/cli/debug/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.cli.debug submodule."""
diff --git a/tests/units/cli/debug/test__init__.py b/tests/units/cli/debug/test__init__.py
index fd3663f..2114f7c 100644
--- a/tests/units/cli/debug/test__init__.py
+++ b/tests/units/cli/debug/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.debug."""
diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py
index c802b0d..35e871a 100644
--- a/tests/units/cli/debug/test_commands.py
+++ b/tests/units/cli/debug/test_commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.debug.commands."""
diff --git a/tests/units/cli/exec/__init__.py b/tests/units/cli/exec/__init__.py
index 4ed48bc..77e4d82 100644
--- a/tests/units/cli/exec/__init__.py
+++ b/tests/units/cli/exec/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.cli.exec submodule."""
diff --git a/tests/units/cli/exec/test__init__.py b/tests/units/cli/exec/test__init__.py
index 124d4af..53544c5 100644
--- a/tests/units/cli/exec/test__init__.py
+++ b/tests/units/cli/exec/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.exec."""
diff --git a/tests/units/cli/exec/test_commands.py b/tests/units/cli/exec/test_commands.py
index 4a72d63..73d7de7 100644
--- a/tests/units/cli/exec/test_commands.py
+++ b/tests/units/cli/exec/test_commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.exec.commands."""
diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py
index 503327a..2222d07 100644
--- a/tests/units/cli/exec/test_utils.py
+++ b/tests/units/cli/exec/test_utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.exec.utils."""
diff --git a/tests/units/cli/get/__init__.py b/tests/units/cli/get/__init__.py
index 5517ded..d4c7c94 100644
--- a/tests/units/cli/get/__init__.py
+++ b/tests/units/cli/get/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.cli.get submodule."""
diff --git a/tests/units/cli/get/local_module/__init__.py b/tests/units/cli/get/local_module/__init__.py
index f93ff2b..b61cf70 100644
--- a/tests/units/cli/get/local_module/__init__.py
+++ b/tests/units/cli/get/local_module/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2024 Arista Networks, Inc.
+# Copyright (c) 2024-2025 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 used for test purposes."""
diff --git a/tests/units/cli/get/test__init__.py b/tests/units/cli/get/test__init__.py
index 1ef65c2..0ece05b 100644
--- a/tests/units/cli/get/test__init__.py
+++ b/tests/units/cli/get/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.get."""
diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py
index 0e263f7..775dcbf 100644
--- a/tests/units/cli/get/test_commands.py
+++ b/tests/units/cli/get/test_commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.get.commands."""
diff --git a/tests/units/cli/get/test_utils.py b/tests/units/cli/get/test_utils.py
index 9cff4ce..b6eebcf 100644
--- a/tests/units/cli/get/test_utils.py
+++ b/tests/units/cli/get/test_utils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.get.utils."""
diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py
index db71b4d..ff70955 100644
--- a/tests/units/cli/nrfu/__init__.py
+++ b/tests/units/cli/nrfu/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.cli.nrfu submodule."""
diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py
index d08499c..bbc8928 100644
--- a/tests/units/cli/nrfu/test__init__.py
+++ b/tests/units/cli/nrfu/test__init__.py
@@ -1,18 +1,22 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.nrfu."""
from __future__ import annotations
+from pathlib import Path
from typing import TYPE_CHECKING
from anta.cli import anta
from anta.cli.utils import ExitCode
if TYPE_CHECKING:
+ import pytest
from click.testing import CliRunner
+DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data"
+
# TODO: write unit tests for ignore-status and ignore-error
@@ -123,3 +127,19 @@ def test_hide(click_runner: CliRunner) -> None:
"""Test the `--hide` option of the `anta nrfu` command."""
result = click_runner.invoke(anta, ["nrfu", "--hide", "success", "text"])
assert "SUCCESS" not in result.output
+
+
+def test_invalid_inventory(caplog: pytest.LogCaptureFixture, click_runner: CliRunner) -> None:
+ """Test invalid inventory."""
+ result = click_runner.invoke(anta, ["nrfu", "--inventory", str(DATA_DIR / "invalid_inventory.yml")])
+ assert "CRITICAL" in caplog.text
+ assert "Failed to parse the inventory" in caplog.text
+ assert result.exit_code == ExitCode.USAGE_ERROR
+
+
+def test_invalid_catalog(caplog: pytest.LogCaptureFixture, click_runner: CliRunner) -> None:
+ """Test invalid catalog."""
+ result = click_runner.invoke(anta, ["nrfu", "--catalog", str(DATA_DIR / "test_catalog_not_a_list.yml")])
+ assert "CRITICAL" in caplog.text
+ assert "Failed to parse the catalog" in caplog.text
+ assert result.exit_code == ExitCode.USAGE_ERROR
diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py
index 372c86a..1910636 100644
--- a/tests/units/cli/nrfu/test_commands.py
+++ b/tests/units/cli/nrfu/test_commands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.nrfu.commands."""
@@ -82,9 +82,9 @@ def test_anta_nrfu_text_multiple_failures(click_runner: CliRunner) -> None:
assert result.exit_code == ExitCode.TESTS_FAILED
assert (
"""spine1 :: VerifyInterfacesSpeed :: FAILURE
- Interface `Ethernet2` is not found.
- Interface `Ethernet3` is not found.
- Interface `Ethernet4` is not found."""
+ Interface: Ethernet2 - Not found
+ Interface: Ethernet3 - Not found
+ Interface: Ethernet4 - Not found"""
in result.output
)
diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py
index 6e32664..308516f 100644
--- a/tests/units/cli/test__init__.py
+++ b/tests/units/cli/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
diff --git a/tests/units/cli/test_main.py b/tests/units/cli/test_main.py
index 31a5e78..8564707 100644
--- a/tests/units/cli/test_main.py
+++ b/tests/units/cli/test_main.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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."""
diff --git a/tests/units/conftest.py b/tests/units/conftest.py
index 665075c..49c786f 100644
--- a/tests/units/conftest.py
+++ b/tests/units/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files."""
diff --git a/tests/units/input_models/__init__.py b/tests/units/input_models/__init__.py
index 62747a6..3014427 100644
--- a/tests/units/input_models/__init__.py
+++ b/tests/units/input_models/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.input_models module."""
diff --git a/tests/units/input_models/routing/__init__.py b/tests/units/input_models/routing/__init__.py
index b56adb5..b8cb099 100644
--- a/tests/units/input_models/routing/__init__.py
+++ b/tests/units/input_models/routing/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 for anta.input_models.routing submodule."""
diff --git a/tests/units/input_models/routing/test_bgp.py b/tests/units/input_models/routing/test_bgp.py
index 66c37af..ee94111 100644
--- a/tests/units/input_models/routing/test_bgp.py
+++ b/tests/units/input_models/routing/test_bgp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.input_models.routing.bgp.py."""
@@ -6,24 +6,29 @@
# pylint: disable=C0302
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
import pytest
from pydantic import ValidationError
-from anta.input_models.routing.bgp import BgpAddressFamily, BgpPeer
+from anta.input_models.routing.bgp import AddressFamilyConfig, BgpAddressFamily, BgpPeer, BgpRoute, RedistributedRouteConfig
from anta.tests.routing.bgp import (
VerifyBGPExchangedRoutes,
+ VerifyBGPNlriAcceptance,
VerifyBGPPeerCount,
+ VerifyBGPPeerGroup,
VerifyBGPPeerMPCaps,
VerifyBGPPeerRouteLimit,
+ VerifyBGPPeerTtlMultiHops,
+ VerifyBGPRouteECMP,
VerifyBgpRouteMaps,
+ VerifyBGPRoutePaths,
VerifyBGPSpecificPeers,
VerifyBGPTimers,
)
if TYPE_CHECKING:
- from anta.custom_types import Afi, Safi
+ from anta.custom_types import Afi, RedistributedAfiSafi, RedistributedProtocol, Safi
class TestBgpAddressFamily:
@@ -116,6 +121,7 @@ class TestVerifyBGPExchangedRoutesInput:
[{"peer_address": "172.30.255.5", "vrf": "default", "advertised_routes": ["192.0.254.5/32"], "received_routes": ["192.0.255.4/32"]}],
id="valid_both_received_advertised",
),
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "advertised_routes": ["192.0.254.5/32"]}], id="valid_advertised_routes"),
],
)
def test_valid(self, bgp_peers: list[BgpPeer]) -> None:
@@ -126,8 +132,6 @@ class TestVerifyBGPExchangedRoutesInput:
("bgp_peers"),
[
pytest.param([{"peer_address": "172.30.255.5", "vrf": "default"}], id="invalid"),
- pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "advertised_routes": ["192.0.254.5/32"]}], id="invalid_received_route"),
- pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "received_routes": ["192.0.254.5/32"]}], id="invalid_advertised_route"),
],
)
def test_invalid(self, bgp_peers: list[BgpPeer]) -> None:
@@ -236,3 +240,271 @@ class TestVerifyBGPPeerRouteLimitInput:
"""Test VerifyBGPPeerRouteLimit.Input invalid inputs."""
with pytest.raises(ValidationError):
VerifyBGPPeerRouteLimit.Input(bgp_peers=bgp_peers)
+
+
+class TestVerifyBGPPeerGroupInput:
+ """Test anta.tests.routing.bgp.VerifyBGPPeerGroup.Input."""
+
+ @pytest.mark.parametrize(
+ ("bgp_peers"),
+ [
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "peer_group": "IPv4-UNDERLAY-PEERS"}], id="valid"),
+ ],
+ )
+ def test_valid(self, bgp_peers: list[BgpPeer]) -> None:
+ """Test VerifyBGPPeerGroup.Input valid inputs."""
+ VerifyBGPPeerGroup.Input(bgp_peers=bgp_peers)
+
+ @pytest.mark.parametrize(
+ ("bgp_peers"),
+ [
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, bgp_peers: list[BgpPeer]) -> None:
+ """Test VerifyBGPPeerGroup.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBGPPeerGroup.Input(bgp_peers=bgp_peers)
+
+
+class TestVerifyBGPNlriAcceptanceInput:
+ """Test anta.tests.routing.bgp.VerifyBGPNlriAcceptance.Input."""
+
+ @pytest.mark.parametrize(
+ ("bgp_peers"),
+ [
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "capabilities": ["ipv4Unicast"]}], id="valid"),
+ ],
+ )
+ def test_valid(self, bgp_peers: list[BgpPeer]) -> None:
+ """Test VerifyBGPNlriAcceptance.Input valid inputs."""
+ VerifyBGPNlriAcceptance.Input(bgp_peers=bgp_peers)
+
+ @pytest.mark.parametrize(
+ ("bgp_peers"),
+ [
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, bgp_peers: list[BgpPeer]) -> None:
+ """Test VerifyBGPNlriAcceptance.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBGPNlriAcceptance.Input(bgp_peers=bgp_peers)
+
+
+class TestVerifyBGPRouteECMPInput:
+ """Test anta.tests.routing.bgp.VerifyBGPRouteECMP.Input."""
+
+ @pytest.mark.parametrize(
+ ("bgp_routes"),
+ [
+ pytest.param([{"prefix": "10.100.0.128/31", "vrf": "default", "ecmp_count": 2}], id="valid"),
+ ],
+ )
+ def test_valid(self, bgp_routes: list[BgpRoute]) -> None:
+ """Test VerifyBGPRouteECMP.Input valid inputs."""
+ VerifyBGPRouteECMP.Input(route_entries=bgp_routes)
+
+ @pytest.mark.parametrize(
+ ("bgp_routes"),
+ [
+ pytest.param([{"prefix": "10.100.0.128/31", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, bgp_routes: list[BgpRoute]) -> None:
+ """Test VerifyBGPRouteECMP.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBGPRouteECMP.Input(route_entries=bgp_routes)
+
+
+class TestVerifyBGPRoutePathsInput:
+ """Test anta.tests.routing.bgp.VerifyBGPRoutePaths.Input."""
+
+ @pytest.mark.parametrize(
+ ("route_entries"),
+ [
+ pytest.param(
+ [
+ {
+ "prefix": "10.100.0.128/31",
+ "vrf": "default",
+ "paths": [{"nexthop": "10.100.0.10", "origin": "Igp"}, {"nexthop": "10.100.4.5", "origin": "Incomplete"}],
+ }
+ ],
+ id="valid",
+ ),
+ ],
+ )
+ def test_valid(self, route_entries: list[BgpRoute]) -> None:
+ """Test VerifyBGPRoutePaths.Input valid inputs."""
+ VerifyBGPRoutePaths.Input(route_entries=route_entries)
+
+ @pytest.mark.parametrize(
+ ("route_entries"),
+ [
+ pytest.param([{"prefix": "10.100.0.128/31", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, route_entries: list[BgpRoute]) -> None:
+ """Test VerifyBGPRoutePaths.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBGPRoutePaths.Input(route_entries=route_entries)
+
+
+class TestVerifyBGPRedistributedRoute:
+ """Test anta.input_models.routing.bgp.RedistributedRouteConfig."""
+
+ @pytest.mark.parametrize(
+ ("proto", "include_leaked"),
+ [
+ pytest.param("Connected", True, id="proto-valid"),
+ pytest.param("Static", False, id="proto-valid-leaked-false"),
+ pytest.param("User", False, id="proto-User"),
+ ],
+ )
+ def test_validate_inputs(self, proto: RedistributedProtocol, include_leaked: bool) -> None:
+ """Test RedistributedRouteConfig valid inputs."""
+ RedistributedRouteConfig(proto=proto, include_leaked=include_leaked)
+
+ @pytest.mark.parametrize(
+ ("proto", "include_leaked"),
+ [
+ pytest.param("Dynamic", True, id="proto-valid"),
+ pytest.param("User", True, id="proto-valid-leaked-false"),
+ ],
+ )
+ def test_invalid(self, proto: RedistributedProtocol, include_leaked: bool) -> None:
+ """Test RedistributedRouteConfig invalid inputs."""
+ with pytest.raises(ValidationError):
+ RedistributedRouteConfig(proto=proto, include_leaked=include_leaked)
+
+ @pytest.mark.parametrize(
+ ("proto", "include_leaked", "route_map", "expected"),
+ [
+ pytest.param("Connected", True, "RM-CONN-2-BGP", "Proto: Connected, Include Leaked: True, Route Map: RM-CONN-2-BGP", id="check-all-params"),
+ pytest.param("Static", False, None, "Proto: Static", id="check-proto-include_leaked-false"),
+ pytest.param("User", False, "RM-CONN-2-BGP", "Proto: EOS SDK, Route Map: RM-CONN-2-BGP", id="check-proto-route_map"),
+ pytest.param("Dynamic", False, None, "Proto: Dynamic", id="check-proto-only"),
+ ],
+ )
+ def test_valid_str(self, proto: RedistributedProtocol, include_leaked: bool, route_map: str | None, expected: str) -> None:
+ """Test RedistributedRouteConfig __str__."""
+ assert str(RedistributedRouteConfig(proto=proto, include_leaked=include_leaked, route_map=route_map)) == expected
+
+
+class TestVerifyBGPAddressFamilyConfig:
+ """Test anta.input_models.routing.bgp.AddressFamilyConfig."""
+
+ @pytest.mark.parametrize(
+ ("afi_safi", "redistributed_routes"),
+ [
+ pytest.param("ipv4Unicast", [{"proto": "OSPFv3 External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv4-unicast"),
+ pytest.param("ipv6 Multicast", [{"proto": "OSPF Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv6-multicast"),
+ pytest.param("ipv4-Multicast", [{"proto": "IS-IS", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv4-multicast"),
+ pytest.param("ipv6_Unicast", [{"proto": "AttachedHost", "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv6-unicast"),
+ ],
+ )
+ def test_valid(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any]) -> None:
+ """Test AddressFamilyConfig valid inputs."""
+ AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes)
+
+ @pytest.mark.parametrize(
+ ("afi_safi", "redistributed_routes"),
+ [
+ pytest.param("evpn", [{"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="invalid-address-family"),
+ pytest.param("ipv6 sr-te", [{"proto": "RIP", "route_map": "RM-CONN-2-BGP"}], id="ipv6-invalid-address-family"),
+ pytest.param("iipv6_Unicast", [{"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="ipv6-unicast-invalid-address-family"),
+ pytest.param("ipv6_Unicastt", [{"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="ipv6-unicast-invalid-address-family"),
+ ],
+ )
+ def test_invalid(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any]) -> None:
+ """Test AddressFamilyConfig invalid inputs."""
+ with pytest.raises(ValidationError):
+ AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes)
+
+ @pytest.mark.parametrize(
+ ("afi_safi", "redistributed_routes"),
+ [
+ pytest.param("ipv4Unicast", [{"proto": "OSPF External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-ospf-external"),
+ pytest.param("ipv4 Unicast", [{"proto": "OSPF Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-ospf-internal"),
+ pytest.param("ipv4-Unicast", [{"proto": "OSPF Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-ospf-nssa-external"),
+ pytest.param("ipv4_Unicast", [{"proto": "OSPFv3 External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-ospfv3-external"),
+ pytest.param("Ipv4Unicast", [{"proto": "OSPFv3 Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-ospfv3-internal"),
+ pytest.param(
+ "ipv4Unicast", [{"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-ospfv3-nssa-external"
+ ),
+ pytest.param("ipv4unicast", [{"proto": "AttachedHost", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-attached-host"),
+ pytest.param("IPv4UNiCast", [{"proto": "RIP", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-rip"),
+ pytest.param("IPv4UnicasT", [{"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4u-proto-bgp"),
+ pytest.param("ipv6_Multicast", [{"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v6m-proto-static"),
+ pytest.param("ipv6 Multicast", [{"proto": "OSPF Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v6m-proto-ospf-internal"),
+ pytest.param("ipv6-Multicast", [{"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v6m-proto-connected"),
+ pytest.param("ipv4-Multicast", [{"proto": "IS-IS", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="v4m-proto-isis"),
+ pytest.param("ipv4Multicast", [{"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="v4m-proto-connected"),
+ pytest.param("ipv4_Multicast", [{"proto": "AttachedHost", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="v4m-proto-attached-host"),
+ pytest.param("ipv6_Unicast", [{"proto": "AttachedHost", "route_map": "RM-CONN-2-BGP"}], id="v6u-proto-attached-host"),
+ pytest.param("ipv6unicast", [{"proto": "DHCP", "route_map": "RM-CONN-2-BGP"}], id="v6u-proto-dhcp"),
+ pytest.param("ipv6 Unicast", [{"proto": "Dynamic", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="v6u-proto-dynamic"),
+ ],
+ )
+ def test_validate_afi_safi_supported_routes(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any]) -> None:
+ """Test AddressFamilyConfig validate afi-safi supported routes."""
+ AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes)
+
+ @pytest.mark.parametrize(
+ ("afi_safi", "redistributed_routes"),
+ [
+ pytest.param("ipv6_Unicast", [{"proto": "RIP", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv6-unicast-rip"),
+ pytest.param("ipv6-Unicast", [{"proto": "OSPF Internal", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv6-unicast-ospf-internal"),
+ pytest.param("ipv4Unicast", [{"proto": "DHCP", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv4-unicast-dhcp"),
+ pytest.param("ipv4-Multicast", [{"proto": "Bgp", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv4-multicast-bgp"),
+ pytest.param("ipv4-Multicast", [{"proto": "RIP", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv4-multicast-rip"),
+ pytest.param("ipv6-Multicast", [{"proto": "Dynamic", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv4-multicast-dynamic"),
+ pytest.param("ipv6-Multicast", [{"proto": "AttachedHost", "route_map": "RM-CONN-2-BGP"}], id="invalid-proto-ipv6-multicast-attached-host"),
+ ],
+ )
+ def test_invalid_afi_safi_supported_routes(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any]) -> None:
+ """Test AddressFamilyConfig invalid afi-safi supported routes."""
+ with pytest.raises(ValidationError):
+ AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes)
+
+ @pytest.mark.parametrize(
+ ("afi_safi", "redistributed_routes", "expected"),
+ [
+ pytest.param(
+ "v4u", [{"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv4 Unicast", id="valid-ipv4-unicast"
+ ),
+ pytest.param("v4m", [{"proto": "IS-IS", "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv4 Multicast", id="valid-ipv4-multicast"),
+ pytest.param("v6u", [{"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv6 Unicast", id="valid-ipv6-unicast"),
+ pytest.param("v6m", [{"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv6 Multicast", id="valid-ipv6-multicast"),
+ ],
+ )
+ def test_valid_str(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any], expected: str) -> None:
+ """Test AddressFamilyConfig __str__."""
+ assert str(AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes)) == expected
+
+
+class TestVerifyBGPPeerTtlMultiHopsInput:
+ """Test anta.tests.routing.bgp.VerifyBGPPeerTtlMultiHops.Input."""
+
+ @pytest.mark.parametrize(
+ ("bgp_peers"),
+ [
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "ttl": 3, "max_ttl_hops": 3}], id="valid"),
+ ],
+ )
+ def test_valid(self, bgp_peers: list[BgpPeer]) -> None:
+ """Test VerifyBGPPeerTtlMultiHops.Input valid inputs."""
+ VerifyBGPPeerTtlMultiHops.Input(bgp_peers=bgp_peers)
+
+ @pytest.mark.parametrize(
+ ("bgp_peers"),
+ [
+ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "ttl": None, "max_ttl_hops": 3}], id="invalid-ttl-time"),
+ pytest.param([{"peer_address": "172.30.255.6", "vrf": "default", "ttl": 3, "max_ttl_hops": None}], id="invalid-max-ttl-hops"),
+ ],
+ )
+ def test_invalid(self, bgp_peers: list[BgpPeer]) -> None:
+ """Test VerifyBGPPeerTtlMultiHops.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBGPPeerTtlMultiHops.Input(bgp_peers=bgp_peers)
diff --git a/tests/units/input_models/routing/test_generic.py b/tests/units/input_models/routing/test_generic.py
new file mode 100644
index 0000000..5e23398
--- /dev/null
+++ b/tests/units/input_models/routing/test_generic.py
@@ -0,0 +1,66 @@
+# Copyright (c) 2023-2025 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.input_models.routing.generic.py."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from pydantic import ValidationError
+
+from anta.tests.routing.generic import VerifyIPv4RouteNextHops, VerifyIPv4RouteType
+
+if TYPE_CHECKING:
+ from anta.input_models.routing.generic import IPv4Routes
+
+
+class TestVerifyRouteEntryInput:
+ """Test anta.tests.routing.generic.VerifyIPv4RouteNextHops.Input."""
+
+ @pytest.mark.parametrize(
+ ("route_entries"),
+ [
+ pytest.param([{"prefix": "10.10.0.1/32", "vrf": "default", "strict": True, "nexthops": ["10.100.0.8", "10.100.0.10"]}], id="valid"),
+ ],
+ )
+ def test_valid(self, route_entries: list[IPv4Routes]) -> None:
+ """Test VerifyIPv4RouteNextHops.Input valid inputs."""
+ VerifyIPv4RouteNextHops.Input(route_entries=route_entries)
+
+ @pytest.mark.parametrize(
+ ("route_entries"),
+ [
+ pytest.param([{"prefix": "10.10.0.1/32", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, route_entries: list[IPv4Routes]) -> None:
+ """Test VerifyIPv4RouteNextHops.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyIPv4RouteNextHops.Input(route_entries=route_entries)
+
+
+class TestVerifyIPv4RouteTypeInput:
+ """Test anta.tests.routing.bgp.VerifyIPv4RouteType.Input."""
+
+ @pytest.mark.parametrize(
+ ("routes_entries"),
+ [
+ pytest.param([{"prefix": "192.168.0.0/24", "vrf": "default", "route_type": "eBGP"}], id="valid"),
+ ],
+ )
+ def test_valid(self, routes_entries: list[IPv4Routes]) -> None:
+ """Test VerifyIPv4RouteType.Input valid inputs."""
+ VerifyIPv4RouteType.Input(routes_entries=routes_entries)
+
+ @pytest.mark.parametrize(
+ ("routes_entries"),
+ [
+ pytest.param([{"prefix": "192.168.0.0/24", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, routes_entries: list[IPv4Routes]) -> None:
+ """Test VerifyIPv4RouteType.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyIPv4RouteType.Input(routes_entries=routes_entries)
diff --git a/tests/units/input_models/routing/test_isis.py b/tests/units/input_models/routing/test_isis.py
new file mode 100644
index 0000000..eeef051
--- /dev/null
+++ b/tests/units/input_models/routing/test_isis.py
@@ -0,0 +1,101 @@
+# Copyright (c) 2023-2025 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.input_models.routing.isis.py."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Literal
+
+import pytest
+from pydantic import ValidationError
+
+from anta.input_models.routing.isis import ISISInstance, TunnelPath
+from anta.tests.routing.isis import VerifyISISSegmentRoutingAdjacencySegments, VerifyISISSegmentRoutingDataplane
+
+if TYPE_CHECKING:
+ from ipaddress import IPv4Address
+
+ from anta.custom_types import Interface
+
+
+class TestVerifyISISSegmentRoutingAdjacencySegmentsInput:
+ """Test anta.tests.routing.isis.VerifyISISSegmentRoutingAdjacencySegments.Input."""
+
+ @pytest.mark.parametrize(
+ ("instances"),
+ [
+ pytest.param(
+ [{"name": "CORE-ISIS", "vrf": "default", "segments": [{"interface": "Ethernet2", "address": "10.0.1.3", "sid_origin": "dynamic"}]}], id="valid_vrf"
+ ),
+ ],
+ )
+ def test_valid(self, instances: list[ISISInstance]) -> None:
+ """Test VerifyISISSegmentRoutingAdjacencySegments.Input valid inputs."""
+ VerifyISISSegmentRoutingAdjacencySegments.Input(instances=instances)
+
+ @pytest.mark.parametrize(
+ ("instances"),
+ [
+ pytest.param(
+ [{"name": "CORE-ISIS", "vrf": "PROD", "segments": [{"interface": "Ethernet2", "address": "10.0.1.3", "sid_origin": "dynamic"}]}], id="invalid_vrf"
+ ),
+ ],
+ )
+ def test_invalid(self, instances: list[ISISInstance]) -> None:
+ """Test VerifyISISSegmentRoutingAdjacencySegments.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyISISSegmentRoutingAdjacencySegments.Input(instances=instances)
+
+
+class TestVerifyISISSegmentRoutingDataplaneInput:
+ """Test anta.tests.routing.isis.VerifyISISSegmentRoutingDataplane.Input."""
+
+ @pytest.mark.parametrize(
+ ("instances"),
+ [
+ pytest.param([{"name": "CORE-ISIS", "vrf": "default", "dataplane": "MPLS"}], id="valid_vrf"),
+ ],
+ )
+ def test_valid(self, instances: list[ISISInstance]) -> None:
+ """Test VerifyISISSegmentRoutingDataplane.Input valid inputs."""
+ VerifyISISSegmentRoutingDataplane.Input(instances=instances)
+
+ @pytest.mark.parametrize(
+ ("instances"),
+ [
+ pytest.param([{"name": "CORE-ISIS", "vrf": "PROD", "dataplane": "MPLS"}], id="invalid_vrf"),
+ ],
+ )
+ def test_invalid(self, instances: list[ISISInstance]) -> None:
+ """Test VerifyISISSegmentRoutingDataplane.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyISISSegmentRoutingDataplane.Input(instances=instances)
+
+
+class TestTunnelPath:
+ """Test anta.input_models.routing.isis.TestTunnelPath."""
+
+ # pylint: disable=too-few-public-methods
+
+ @pytest.mark.parametrize(
+ ("nexthop", "type", "interface", "tunnel_id", "expected"),
+ [
+ pytest.param("1.1.1.1", None, None, None, "Next-hop: 1.1.1.1", id="nexthop"),
+ pytest.param(None, "ip", None, None, "Type: ip", id="type"),
+ pytest.param(None, None, "Et1", None, "Interface: Ethernet1", id="interface"),
+ pytest.param(None, None, None, "TI-LFA", "Tunnel ID: TI-LFA", id="tunnel_id"),
+ pytest.param("1.1.1.1", "ip", "Et1", "TI-LFA", "Next-hop: 1.1.1.1 Type: ip Interface: Ethernet1 Tunnel ID: TI-LFA", id="all"),
+ pytest.param(None, None, None, None, "", id="None"),
+ ],
+ )
+ def test_valid__str__(
+ self,
+ nexthop: IPv4Address | None,
+ type: Literal["ip", "tunnel"] | None, # noqa: A002
+ interface: Interface | None,
+ tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None,
+ expected: str,
+ ) -> None:
+ """Test TunnelPath __str__."""
+ assert str(TunnelPath(nexthop=nexthop, type=type, interface=interface, tunnel_id=tunnel_id)) == expected
diff --git a/tests/units/input_models/test_bfd.py b/tests/units/input_models/test_bfd.py
new file mode 100644
index 0000000..e179f39
--- /dev/null
+++ b/tests/units/input_models/test_bfd.py
@@ -0,0 +1,68 @@
+# Copyright (c) 2023-2025 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.input_models.bfd.py."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from pydantic import ValidationError
+
+from anta.tests.bfd import VerifyBFDPeersIntervals, VerifyBFDPeersRegProtocols
+
+if TYPE_CHECKING:
+ from anta.input_models.bfd import BFDPeer
+
+
+class TestVerifyBFDPeersIntervalsInput:
+ """Test anta.tests.bfd.VerifyBFDPeersIntervals.Input."""
+
+ @pytest.mark.parametrize(
+ ("bfd_peers"),
+ [
+ pytest.param([{"peer_address": "10.0.0.1", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3}], id="valid"),
+ ],
+ )
+ def test_valid(self, bfd_peers: list[BFDPeer]) -> None:
+ """Test VerifyBFDPeersIntervals.Input valid inputs."""
+ VerifyBFDPeersIntervals.Input(bfd_peers=bfd_peers)
+
+ @pytest.mark.parametrize(
+ ("bfd_peers"),
+ [
+ pytest.param([{"peer_address": "10.0.0.1", "vrf": "default", "tx_interval": 1200}], id="invalid-tx-interval"),
+ pytest.param([{"peer_address": "10.0.0.1", "vrf": "default", "rx_interval": 1200}], id="invalid-rx-interval"),
+ pytest.param([{"peer_address": "10.0.0.1", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200}], id="invalid-multiplier"),
+ ],
+ )
+ def test_invalid(self, bfd_peers: list[BFDPeer]) -> None:
+ """Test VerifyBFDPeersIntervals.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBFDPeersIntervals.Input(bfd_peers=bfd_peers)
+
+
+class TestVerifyBFDPeersRegProtocolsInput:
+ """Test anta.tests.bfd.VerifyBFDPeersRegProtocols.Input."""
+
+ @pytest.mark.parametrize(
+ ("bfd_peers"),
+ [
+ pytest.param([{"peer_address": "10.0.0.1", "vrf": "default", "protocols": ["bgp"]}], id="valid"),
+ ],
+ )
+ def test_valid(self, bfd_peers: list[BFDPeer]) -> None:
+ """Test VerifyBFDPeersRegProtocols.Input valid inputs."""
+ VerifyBFDPeersRegProtocols.Input(bfd_peers=bfd_peers)
+
+ @pytest.mark.parametrize(
+ ("bfd_peers"),
+ [
+ pytest.param([{"peer_address": "10.0.0.1", "vrf": "default"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, bfd_peers: list[BFDPeer]) -> None:
+ """Test VerifyBFDPeersRegProtocols.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyBFDPeersRegProtocols.Input(bfd_peers=bfd_peers)
diff --git a/tests/units/input_models/test_connectivity.py b/tests/units/input_models/test_connectivity.py
new file mode 100644
index 0000000..9e4288c
--- /dev/null
+++ b/tests/units/input_models/test_connectivity.py
@@ -0,0 +1,43 @@
+# Copyright (c) 2023-2025 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.input_models.connectivity.py."""
+
+# pylint: disable=C0302
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from pydantic import ValidationError
+
+from anta.tests.connectivity import VerifyReachability
+
+if TYPE_CHECKING:
+ from anta.input_models.connectivity import Host
+
+
+class TestVerifyReachabilityInput:
+ """Test anta.tests.connectivity.VerifyReachability.Input."""
+
+ @pytest.mark.parametrize(
+ ("hosts"),
+ [
+ pytest.param([{"destination": "fd12:3456:789a:1::2", "source": "fd12:3456:789a:1::1"}], id="valid"),
+ ],
+ )
+ def test_valid(self, hosts: list[Host]) -> None:
+ """Test VerifyReachability.Input valid inputs."""
+ VerifyReachability.Input(hosts=hosts)
+
+ @pytest.mark.parametrize(
+ ("hosts"),
+ [
+ pytest.param([{"destination": "fd12:3456:789a:1::2", "source": "192.168.0.10"}], id="invalid-source"),
+ pytest.param([{"destination": "192.168.0.10", "source": "fd12:3456:789a:1::2"}], id="invalid-destination"),
+ ],
+ )
+ def test_invalid(self, hosts: list[Host]) -> None:
+ """Test VerifyReachability.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyReachability.Input(hosts=hosts)
diff --git a/tests/units/input_models/test_interfaces.py b/tests/units/input_models/test_interfaces.py
index 87d742d..c4bb8ef 100644
--- a/tests/units/input_models/test_interfaces.py
+++ b/tests/units/input_models/test_interfaces.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.input_models.interfaces.py."""
@@ -9,8 +9,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
+from pydantic import ValidationError
from anta.input_models.interfaces import InterfaceState
+from anta.tests.interfaces import VerifyInterfaceIPv4, VerifyInterfacesSpeed, VerifyInterfacesStatus, VerifyLACPInterfacesStatus
if TYPE_CHECKING:
from anta.custom_types import Interface, PortChannelInterface
@@ -31,3 +33,103 @@ class TestInterfaceState:
def test_valid__str__(self, name: Interface, portchannel: PortChannelInterface | None, expected: str) -> None:
"""Test InterfaceState __str__."""
assert str(InterfaceState(name=name, portchannel=portchannel)) == expected
+
+
+class TestVerifyInterfacesStatusInput:
+ """Test anta.tests.interfaces.VerifyInterfacesStatus.Input."""
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1", "status": "up"}], id="valid"),
+ ],
+ )
+ def test_valid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyInterfacesStatus.Input valid inputs."""
+ VerifyInterfacesStatus.Input(interfaces=interfaces)
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyInterfacesStatus.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyInterfacesStatus.Input(interfaces=interfaces)
+
+
+class TestVerifyLACPInterfacesStatusInput:
+ """Test anta.tests.interfaces.VerifyLACPInterfacesStatus.Input."""
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1", "portchannel": "Port-Channel100"}], id="valid"),
+ ],
+ )
+ def test_valid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyLACPInterfacesStatus.Input valid inputs."""
+ VerifyLACPInterfacesStatus.Input(interfaces=interfaces)
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1"}], id="invalid"),
+ ],
+ )
+ def test_invalid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyLACPInterfacesStatus.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyLACPInterfacesStatus.Input(interfaces=interfaces)
+
+
+class TestVerifyInterfaceIPv4Input:
+ """Test anta.tests.interfaces.VerifyInterfaceIPv4.Input."""
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1", "primary_ip": "172.30.11.1/31"}], id="valid"),
+ ],
+ )
+ def test_valid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyInterfaceIPv4.Input valid inputs."""
+ VerifyInterfaceIPv4.Input(interfaces=interfaces)
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1"}], id="invalid-no-primary-ip"),
+ ],
+ )
+ def test_invalid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyInterfaceIPv4.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyInterfaceIPv4.Input(interfaces=interfaces)
+
+
+class TestVerifyInterfacesSpeedInput:
+ """Test anta.tests.interfaces.VerifyInterfacesSpeed.Input."""
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1", "speed": 10}], id="valid-speed-is-given"),
+ ],
+ )
+ def test_valid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyInterfacesSpeed.Input valid inputs."""
+ VerifyInterfacesSpeed.Input(interfaces=interfaces)
+
+ @pytest.mark.parametrize(
+ ("interfaces"),
+ [
+ pytest.param([{"name": "Ethernet1"}], id="invalid-speed-is-not-given"),
+ ],
+ )
+ def test_invalid(self, interfaces: list[InterfaceState]) -> None:
+ """Test VerifyInterfacesSpeed.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyInterfacesSpeed.Input(interfaces=interfaces)
diff --git a/tests/units/input_models/test_snmp.py b/tests/units/input_models/test_snmp.py
new file mode 100644
index 0000000..8418955
--- /dev/null
+++ b/tests/units/input_models/test_snmp.py
@@ -0,0 +1,192 @@
+# Copyright (c) 2023-2025 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.input_models.snmp.py."""
+
+# pylint: disable=C0302
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from pydantic import ValidationError
+
+from anta.input_models.snmp import SnmpGroup
+from anta.tests.snmp import VerifySnmpNotificationHost, VerifySnmpUser
+
+if TYPE_CHECKING:
+ from anta.custom_types import SnmpVersion, SnmpVersionV3AuthType
+ from anta.input_models.snmp import SnmpHost, SnmpUser
+
+
+class TestVerifySnmpUserInput:
+ """Test anta.tests.snmp.VerifySnmpUser.Input."""
+
+ @pytest.mark.parametrize(
+ ("snmp_users"),
+ [
+ pytest.param([{"username": "test", "group_name": "abc", "version": "v1", "auth_type": None, "priv_type": None}], id="valid-v1"),
+ pytest.param([{"username": "test", "group_name": "abc", "version": "v2c", "auth_type": None, "priv_type": None}], id="valid-v2c"),
+ pytest.param([{"username": "test", "group_name": "abc", "version": "v3", "auth_type": "SHA", "priv_type": "AES-128"}], id="valid-v3"),
+ ],
+ )
+ def test_valid(self, snmp_users: list[SnmpUser]) -> None:
+ """Test VerifySnmpUser.Input valid inputs."""
+ VerifySnmpUser.Input(snmp_users=snmp_users)
+
+ @pytest.mark.parametrize(
+ ("snmp_users"),
+ [
+ pytest.param([{"username": "test", "group_name": "abc", "version": "v3", "auth_type": None, "priv_type": None}], id="invalid-v3"),
+ ],
+ )
+ def test_invalid(self, snmp_users: list[SnmpUser]) -> None:
+ """Test VerifySnmpUser.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifySnmpUser.Input(snmp_users=snmp_users)
+
+
+class TestSnmpHost:
+ """Test anta.input_models.snmp.SnmpHost."""
+
+ @pytest.mark.parametrize(
+ ("notification_hosts"),
+ [
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": "v1",
+ "udp_port": 162,
+ "community_string": "public",
+ "user": None,
+ }
+ ],
+ id="valid-v1",
+ ),
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": "v2c",
+ "udp_port": 162,
+ "community_string": "public",
+ "user": None,
+ }
+ ],
+ id="valid-v2c",
+ ),
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": "v3",
+ "udp_port": 162,
+ "community_string": None,
+ "user": "public",
+ }
+ ],
+ id="valid-v3",
+ ),
+ ],
+ )
+ def test_valid(self, notification_hosts: list[SnmpHost]) -> None:
+ """Test VerifySnmpNotificationHost.Input valid inputs."""
+ VerifySnmpNotificationHost.Input(notification_hosts=notification_hosts)
+
+ @pytest.mark.parametrize(
+ ("notification_hosts"),
+ [
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": None,
+ "udp_port": 162,
+ "community_string": None,
+ "user": None,
+ }
+ ],
+ id="invalid-version",
+ ),
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": "v1",
+ "udp_port": 162,
+ "community_string": None,
+ "user": None,
+ }
+ ],
+ id="invalid-community-string-version-v1",
+ ),
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": "v2c",
+ "udp_port": 162,
+ "community_string": None,
+ "user": None,
+ }
+ ],
+ id="invalid-community-string-version-v2c",
+ ),
+ pytest.param(
+ [
+ {
+ "hostname": "192.168.1.100",
+ "vrf": "test",
+ "notification_type": "trap",
+ "version": "v3",
+ "udp_port": 162,
+ "community_string": None,
+ "user": None,
+ }
+ ],
+ id="invalid-user-version-v3",
+ ),
+ ],
+ )
+ def test_invalid(self, notification_hosts: list[SnmpHost]) -> None:
+ """Test VerifySnmpNotificationHost.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifySnmpNotificationHost.Input(notification_hosts=notification_hosts)
+
+
+class TestSnmpGroupInput:
+ """Test anta.input_models.snmp.SnmpGroup."""
+
+ @pytest.mark.parametrize(
+ ("group_name", "version", "read_view", "write_view", "notify_view", "authentication"),
+ [
+ pytest.param("group1", "v3", "", "write_1", None, "auth", id="snmp-auth"),
+ ],
+ )
+ def test_valid(self, group_name: str, read_view: str, version: SnmpVersion, write_view: str, notify_view: str, authentication: SnmpVersionV3AuthType) -> None:
+ """Test SnmpGroup valid inputs."""
+ SnmpGroup(group_name=group_name, version=version, read_view=read_view, write_view=write_view, notify_view=notify_view, authentication=authentication)
+
+ @pytest.mark.parametrize(
+ ("group_name", "version", "read_view", "write_view", "notify_view", "authentication"),
+ [
+ pytest.param("group1", "v3", "", "write_1", None, None, id="snmp-invalid-auth"),
+ ],
+ )
+ def test_invalid(self, group_name: str, read_view: str, version: SnmpVersion, write_view: str, notify_view: str, authentication: SnmpVersionV3AuthType) -> None:
+ """Test SnmpGroup invalid inputs."""
+ with pytest.raises(ValidationError):
+ SnmpGroup(group_name=group_name, version=version, read_view=read_view, write_view=write_view, notify_view=notify_view, authentication=authentication)
diff --git a/tests/units/input_models/test_system.py b/tests/units/input_models/test_system.py
new file mode 100644
index 0000000..1d2d86f
--- /dev/null
+++ b/tests/units/input_models/test_system.py
@@ -0,0 +1,48 @@
+# Copyright (c) 2023-2025 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.input_models.system.py."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from pydantic import ValidationError
+
+from anta.tests.system import VerifyNTPAssociations
+
+if TYPE_CHECKING:
+ from anta.input_models.system import NTPPool, NTPServer
+
+
+class TestVerifyNTPAssociationsInput:
+ """Test anta.tests.system.VerifyNTPAssociations.Input."""
+
+ @pytest.mark.parametrize(
+ ("ntp_servers", "ntp_pool"),
+ [
+ pytest.param([{"server_address": "1.1.1.1", "preferred": True, "stratum": 1}], None, id="valid-ntp-server"),
+ pytest.param(None, {"server_addresses": ["1.1.1.1"], "preferred_stratum_range": [1, 3]}, id="valid-ntp-pool"),
+ ],
+ )
+ def test_valid(self, ntp_servers: list[NTPServer], ntp_pool: NTPPool) -> None:
+ """Test VerifyNTPAssociations.Input valid inputs."""
+ VerifyNTPAssociations.Input(ntp_servers=ntp_servers, ntp_pool=ntp_pool)
+
+ @pytest.mark.parametrize(
+ ("ntp_servers", "ntp_pool"),
+ [
+ pytest.param(
+ [{"server_address": "1.1.1.1", "preferred": True, "stratum": 1}],
+ {"server_addresses": ["1.1.1.1"], "preferred_stratum_range": [1, 3]},
+ id="invalid-both-server-pool",
+ ),
+ pytest.param(None, {"server_addresses": ["1.1.1.1"], "preferred_stratum_range": [1, 3, 6]}, id="invalid-ntp-pool-stratum"),
+ pytest.param(None, None, id="invalid-both-none"),
+ ],
+ )
+ def test_invalid(self, ntp_servers: list[NTPServer], ntp_pool: NTPPool) -> None:
+ """Test VerifyNTPAssociations.Input invalid inputs."""
+ with pytest.raises(ValidationError):
+ VerifyNTPAssociations.Input(ntp_servers=ntp_servers, ntp_pool=ntp_pool)
diff --git a/tests/units/inventory/__init__.py b/tests/units/inventory/__init__.py
index 70fbdda..3b03ef0 100644
--- a/tests/units/inventory/__init__.py
+++ b/tests/units/inventory/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 inventory submodule."""
diff --git a/tests/units/inventory/test__init__.py b/tests/units/inventory/test__init__.py
index 20a794a..461f037 100644
--- a/tests/units/inventory/test__init__.py
+++ b/tests/units/inventory/test__init__.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 Inventory unit tests."""
from __future__ import annotations
-from pathlib import Path
+import logging
from typing import TYPE_CHECKING
import pytest
@@ -15,11 +15,10 @@ from anta.inventory import AntaInventory
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
if TYPE_CHECKING:
+ from pathlib import Path
+
from _pytest.mark.structures import ParameterSet
-FILE_DIR: Path = Path(__file__).parent.parent.resolve() / "data" / "inventory"
-
-
INIT_VALID_PARAMS: list[ParameterSet] = [
pytest.param(
{"anta_inventory": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}, {"host": "my.awesome.host.com"}]}},
@@ -76,3 +75,15 @@ class TestAntaInventory:
"""Parse invalid YAML file to create ANTA inventory."""
with pytest.raises((InventoryIncorrectSchemaError, InventoryRootKeyError, ValidationError)):
AntaInventory.parse(filename=yaml_file, username="arista", password="arista123")
+
+ def test_parse_wrong_format(self) -> None:
+ """Use wrong file format to parse the ANTA inventory."""
+ with pytest.raises(ValueError, match=" is not a valid format for an AntaInventory file. Only 'yaml' and 'json' are supported."):
+ AntaInventory.parse(filename="dummy.yml", username="arista", password="arista123", file_format="wrong") # type: ignore[arg-type]
+
+ def test_parse_os_error(self, caplog: pytest.LogCaptureFixture) -> None:
+ """Use wrong file name to parse the ANTA inventory."""
+ caplog.set_level(logging.INFO)
+ with pytest.raises(OSError, match="No such file or directory"):
+ _ = AntaInventory.parse(filename="dummy.yml", username="arista", password="arista123")
+ assert "Unable to parse ANTA Device Inventory file" in caplog.records[0].message
diff --git a/tests/units/inventory/test_models.py b/tests/units/inventory/test_models.py
index dfe9722..f6bd7d6 100644
--- a/tests/units/inventory/test_models.py
+++ b/tests/units/inventory/test_models.py
@@ -1,20 +1,25 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 Inventory models unit tests."""
from __future__ import annotations
+import json
+from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from pydantic import ValidationError
+from yaml import safe_load
-from anta.inventory.models import AntaInventoryHost, AntaInventoryNetwork, AntaInventoryRange
+from anta.inventory.models import AntaInventoryHost, AntaInventoryInput, AntaInventoryNetwork, AntaInventoryRange
if TYPE_CHECKING:
from _pytest.mark.structures import ParameterSet
+FILE_DIR: Path = Path(__file__).parents[2] / "data"
+
INVENTORY_HOST_VALID_PARAMS: list[ParameterSet] = [
pytest.param(None, "1.1.1.1", None, None, None, id="IPv4"),
pytest.param(None, "fe80::cc62:a9ff:feef:932a", None, None, None, id="IPv6"),
@@ -164,3 +169,33 @@ class TestAntaInventoryRange:
"""Invalid model parameters."""
with pytest.raises(ValidationError):
AntaInventoryRange.model_validate({"start": start, "end": end, "tags": tags, "disable_cache": disable_cache})
+
+
+class TestAntaInventoryInputs:
+ """Test anta.inventory.models.AntaInventoryInputs."""
+
+ def test_dump_to_json(self) -> None:
+ """Load a YAML file, dump it to JSON and verify it works."""
+ input_yml_path = FILE_DIR / "test_inventory_with_tags.yml"
+ expected_json_path = FILE_DIR / "test_inventory_with_tags.json"
+ with input_yml_path.open("r") as f:
+ data = safe_load(f)
+ anta_inventory_input = AntaInventoryInput(**data["anta_inventory"])
+
+ with expected_json_path.open("r") as f:
+ expected_data = json.load(f)
+
+ assert anta_inventory_input.to_json() == json.dumps(expected_data["anta_inventory"], indent=2)
+
+ def test_dump_to_yaml(self) -> None:
+ """Load a JSON file, dump it to YAML and verify it works."""
+ input_json_path = FILE_DIR / "test_inventory_medium.json"
+ expected_yml_path = FILE_DIR / "test_inventory_medium.yml"
+ with input_json_path.open("r") as f:
+ data = json.load(f)
+ anta_inventory_input = AntaInventoryInput(**data["anta_inventory"])
+
+ with expected_yml_path.open("r") as f:
+ expected_data = safe_load(f)
+
+ assert safe_load(anta_inventory_input.yaml()) == expected_data["anta_inventory"]
diff --git a/tests/units/reporter/__init__.py b/tests/units/reporter/__init__.py
index 6e606e5..508daa0 100644
--- a/tests/units/reporter/__init__.py
+++ b/tests/units/reporter/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.reporter submodule."""
diff --git a/tests/units/reporter/conftest.py b/tests/units/reporter/conftest.py
index d0eed36..0baa5c3 100644
--- a/tests/units/reporter/conftest.py
+++ b/tests/units/reporter/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files."""
diff --git a/tests/units/reporter/test__init__.py b/tests/units/reporter/test__init__.py
index 71cccdd..cc34cce 100644
--- a/tests/units/reporter/test__init__.py
+++ b/tests/units/reporter/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.report.__init__.py."""
diff --git a/tests/units/reporter/test_csv.py b/tests/units/reporter/test_csv.py
index d88098e..8244951 100644
--- a/tests/units/reporter/test_csv.py
+++ b/tests/units/reporter/test_csv.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.report.csv_reporter.py."""
diff --git a/tests/units/reporter/test_md_reporter.py b/tests/units/reporter/test_md_reporter.py
index c0676bb..f5d2423 100644
--- a/tests/units/reporter/test_md_reporter.py
+++ b/tests/units/reporter/test_md_reporter.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.reporter.md_reporter.py."""
from __future__ import annotations
-from io import BytesIO, TextIOWrapper
+from io import StringIO
from pathlib import Path
import pytest
@@ -22,7 +22,7 @@ def test_md_report_generate(tmp_path: Path, result_manager: ResultManager) -> No
expected_report = "test_md_report.md"
# Generate the Markdown report
- MDReportGenerator.generate(result_manager, md_filename)
+ MDReportGenerator.generate(result_manager.sort(sort_by=["name", "categories", "test"]), md_filename)
assert md_filename.exists()
# Load the existing Markdown report to compare with the generated one
@@ -46,7 +46,7 @@ def test_md_report_base() -> None:
results = ResultManager()
- with TextIOWrapper(BytesIO(b"1 2 3")) as mock_file:
+ with StringIO() as mock_file:
report = FakeMDReportBase(mock_file, results)
assert report.generate_heading_name() == "Fake MD Report Base"
diff --git a/tests/units/result_manager/__init__.py b/tests/units/result_manager/__init__.py
index 861145b..fe9e668 100644
--- a/tests/units/result_manager/__init__.py
+++ b/tests/units/result_manager/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.result_manager submodule."""
diff --git a/tests/units/result_manager/conftest.py b/tests/units/result_manager/conftest.py
index 2c5dc8a..e414c46 100644
--- a/tests/units/result_manager/conftest.py
+++ b/tests/units/result_manager/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files."""
@@ -34,12 +34,12 @@ def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]
def result_manager() -> ResultManager:
"""Return a ResultManager with 30 random tests loaded from a JSON file.
- Devices: DC1-SPINE1, DC1-LEAF1A
+ Devices: s1-spine1
- Total tests: 30
- - Success: 7
- - Skipped: 2
- - Failure: 19
+ - Success: 4
+ - Skipped: 9
+ - Failure: 15
- Error: 2
See `tests/units/result_manager/test_md_report_results.json` for details.
diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py
index e41a436..e70dbf9 100644
--- a/tests/units/result_manager/test__init__.py
+++ b/tests/units/result_manager/test__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.result_manager.__init__.py."""
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
from anta.result_manager.models import TestResult
+# pylint: disable=too-many-public-methods
class TestResultManager:
"""Test ResultManager class."""
@@ -73,8 +74,8 @@ class TestResultManager:
assert test.get("custom_field") is None
assert test.get("result") == "success"
- def test_sorted_category_stats(self, list_result_factory: Callable[[int], list[TestResult]]) -> None:
- """Test ResultManager.sorted_category_stats."""
+ def test_category_stats(self, list_result_factory: Callable[[int], list[TestResult]]) -> None:
+ """Test ResultManager.category_stats."""
result_manager = ResultManager()
results = list_result_factory(4)
@@ -86,13 +87,9 @@ class TestResultManager:
result_manager.results = results
- # Check the current categories order
- expected_order = ["ospf", "bgp", "vxlan", "system"]
- assert list(result_manager.category_stats.keys()) == expected_order
-
- # Check the sorted categories order
+ # Check that category_stats returns sorted order by default
expected_order = ["bgp", "ospf", "system", "vxlan"]
- assert list(result_manager.sorted_category_stats.keys()) == expected_order
+ assert list(result_manager.category_stats.keys()) == expected_order
@pytest.mark.parametrize(
("starting_status", "test_status", "expected_status", "expected_raise"),
@@ -198,12 +195,12 @@ class TestResultManager:
"""Test ResultManager.get_results."""
# Check for single status
success_results = result_manager.get_results(status={AntaTestStatus.SUCCESS})
- assert len(success_results) == 7
+ assert len(success_results) == 4
assert all(r.result == "success" for r in success_results)
# Check for multiple statuses
failure_results = result_manager.get_results(status={AntaTestStatus.FAILURE, AntaTestStatus.ERROR})
- assert len(failure_results) == 21
+ assert len(failure_results) == 17
assert all(r.result in {"failure", "error"} for r in failure_results)
# Check all results
@@ -215,19 +212,18 @@ class TestResultManager:
# Check all results with sort_by result
all_results = result_manager.get_results(sort_by=["result"])
assert len(all_results) == 30
- assert [r.result for r in all_results] == ["error"] * 2 + ["failure"] * 19 + ["skipped"] * 2 + ["success"] * 7
+ assert [r.result for r in all_results] == ["error"] * 2 + ["failure"] * 15 + ["skipped"] * 9 + ["success"] * 4
# Check all results with sort_by device (name)
all_results = result_manager.get_results(sort_by=["name"])
assert len(all_results) == 30
- assert all_results[0].name == "DC1-LEAF1A"
- assert all_results[-1].name == "DC1-SPINE1"
+ assert all_results[0].name == "s1-spine1"
# Check multiple statuses with sort_by categories
success_skipped_results = result_manager.get_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.SKIPPED}, sort_by=["categories"])
- assert len(success_skipped_results) == 9
- assert success_skipped_results[0].categories == ["Interfaces"]
- assert success_skipped_results[-1].categories == ["VXLAN"]
+ assert len(success_skipped_results) == 13
+ assert success_skipped_results[0].categories == ["avt"]
+ assert success_skipped_results[-1].categories == ["vxlan"]
# Check all results with bad sort_by
with pytest.raises(
@@ -244,14 +240,14 @@ class TestResultManager:
assert result_manager.get_total_results() == 30
# Test single status
- assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS}) == 7
- assert result_manager.get_total_results(status={AntaTestStatus.FAILURE}) == 19
+ assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS}) == 4
+ assert result_manager.get_total_results(status={AntaTestStatus.FAILURE}) == 15
assert result_manager.get_total_results(status={AntaTestStatus.ERROR}) == 2
- assert result_manager.get_total_results(status={AntaTestStatus.SKIPPED}) == 2
+ assert result_manager.get_total_results(status={AntaTestStatus.SKIPPED}) == 9
# Test multiple statuses
- assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE}) == 26
- assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE, AntaTestStatus.ERROR}) == 28
+ assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE}) == 19
+ assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE, AntaTestStatus.ERROR}) == 21
assert result_manager.get_total_results(status={AntaTestStatus.SUCCESS, AntaTestStatus.FAILURE, AntaTestStatus.ERROR, AntaTestStatus.SKIPPED}) == 30
@pytest.mark.parametrize(
@@ -465,7 +461,6 @@ class TestResultManager:
with caplog.at_level(logging.INFO):
_ = result_manager.category_stats
_ = result_manager.test_stats
- _ = result_manager.sorted_category_stats
assert "Computing statistics" not in caplog.text
# Add another result - should mark stats as unsynced
@@ -480,3 +475,89 @@ class TestResultManager:
_ = result_manager.device_stats
assert "Computing statistics for all results" in caplog.text
assert result_manager._stats_in_sync is True
+
+ def test_sort_by_result(self, test_result_factory: Callable[[], TestResult]) -> None:
+ """Test sorting by result."""
+ result_manager = ResultManager()
+ test1 = test_result_factory()
+ test1.result = AntaTestStatus.SUCCESS
+ test2 = test_result_factory()
+ test2.result = AntaTestStatus.FAILURE
+ test3 = test_result_factory()
+ test3.result = AntaTestStatus.ERROR
+
+ result_manager.results = [test1, test2, test3]
+ sorted_manager = result_manager.sort(["result"])
+ assert [r.result for r in sorted_manager.results] == ["error", "failure", "success"]
+
+ def test_sort_by_name(self, test_result_factory: Callable[[], TestResult]) -> None:
+ """Test sorting by name."""
+ result_manager = ResultManager()
+ test1 = test_result_factory()
+ test1.name = "Device3"
+ test2 = test_result_factory()
+ test2.name = "Device1"
+ test3 = test_result_factory()
+ test3.name = "Device2"
+
+ result_manager.results = [test1, test2, test3]
+ sorted_manager = result_manager.sort(["name"])
+ assert [r.name for r in sorted_manager.results] == ["Device1", "Device2", "Device3"]
+
+ def test_sort_by_categories(self, test_result_factory: Callable[[], TestResult]) -> None:
+ """Test sorting by categories."""
+ result_manager = ResultManager()
+ test1 = test_result_factory()
+ test1.categories = ["VXLAN", "networking"]
+ test2 = test_result_factory()
+ test2.categories = ["BGP", "routing"]
+ test3 = test_result_factory()
+ test3.categories = ["system", "hardware"]
+
+ result_manager.results = [test1, test2, test3]
+ sorted_manager = result_manager.sort(["categories"])
+ results = sorted_manager.results
+
+ assert results[0].categories == ["BGP", "routing"]
+ assert results[1].categories == ["VXLAN", "networking"]
+ assert results[2].categories == ["system", "hardware"]
+
+ def test_sort_multiple_fields(self, test_result_factory: Callable[[], TestResult]) -> None:
+ """Test sorting by multiple fields."""
+ result_manager = ResultManager()
+ test1 = test_result_factory()
+ test1.result = AntaTestStatus.ERROR
+ test1.test = "Test3"
+ test2 = test_result_factory()
+ test2.result = AntaTestStatus.ERROR
+ test2.test = "Test1"
+ test3 = test_result_factory()
+ test3.result = AntaTestStatus.FAILURE
+ test3.test = "Test2"
+
+ result_manager.results = [test1, test2, test3]
+ sorted_manager = result_manager.sort(["result", "test"])
+ results = sorted_manager.results
+
+ assert results[0].result == "error"
+ assert results[0].test == "Test1"
+ assert results[1].result == "error"
+ assert results[1].test == "Test3"
+ assert results[2].result == "failure"
+ assert results[2].test == "Test2"
+
+ def test_sort_invalid_field(self) -> None:
+ """Test that sort method raises ValueError for invalid sort_by fields."""
+ result_manager = ResultManager()
+ with pytest.raises(
+ ValueError,
+ match=re.escape(
+ "Invalid sort_by fields: ['bad_field']. Accepted fields are: ['name', 'test', 'categories', 'description', 'result', 'messages', 'custom_field']",
+ ),
+ ):
+ result_manager.sort(["bad_field"])
+
+ def test_sort_is_chainable(self) -> None:
+ """Test that the sort method is chainable."""
+ result_manager = ResultManager()
+ assert isinstance(result_manager.sort(["name"]), ResultManager)
diff --git a/tests/units/result_manager/test_files/test_md_report_results.json b/tests/units/result_manager/test_files/test_md_report_results.json
index b9ecc0c..ab932dc 100644
--- a/tests/units/result_manager/test_files/test_md_report_results.json
+++ b/tests/units/result_manager/test_files/test_md_report_results.json
@@ -1,378 +1,389 @@
[
- {
- "name": "DC1-SPINE1",
- "test": "VerifyTacacsSourceIntf",
- "categories": [
- "AAA"
- ],
- "description": "Verifies TACACS source-interface for a specified VRF.",
- "result": "failure",
- "messages": [
- "Source-interface Management0 is not configured in VRF default"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyLLDPNeighbors",
- "categories": [
- "Connectivity"
- ],
- "description": "Verifies that the provided LLDP neighbors are connected properly.",
- "result": "failure",
- "messages": [
- "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-LEAF1A_Ethernet1\n Ethernet2\n DC1-LEAF1B_Ethernet1\nPort(s) not configured:\n Ethernet7"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyBGPPeerCount",
- "categories": [
- "BGP"
- ],
- "description": "Verifies the count of BGP peers.",
- "result": "failure",
- "messages": [
- "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Not Configured', 'default': 'Expected: 3, Actual: 4'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, {'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 4'}}]"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifySTPMode",
- "categories": [
- "STP"
- ],
- "description": "Verifies the configured STP mode for a provided list of VLAN(s).",
- "result": "failure",
- "messages": [
- "STP mode 'rapidPvst' not configured for the following VLAN(s): [10, 20]"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifySnmpStatus",
- "categories": [
- "SNMP"
- ],
- "description": "Verifies if the SNMP agent is enabled.",
- "result": "failure",
- "messages": [
- "SNMP agent disabled in vrf default"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyRoutingTableEntry",
- "categories": [
- "Routing"
- ],
- "description": "Verifies that the provided routes are present in the routing table of a specified VRF.",
- "result": "failure",
- "messages": [
- "The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyInterfaceUtilization",
- "categories": [
- "Interfaces"
- ],
- "description": "Verifies that the utilization of interfaces is below a certain threshold.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyMlagStatus",
- "categories": [
- "MLAG"
- ],
- "description": "Verifies the health status of the MLAG configuration.",
- "result": "skipped",
- "messages": [
- "MLAG is disabled"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyVxlan1Interface",
- "categories": [
- "VXLAN"
- ],
- "description": "Verifies the Vxlan1 interface status.",
- "result": "skipped",
- "messages": [
- "Vxlan1 interface is not configured"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyBFDSpecificPeers",
- "categories": [
- "BFD"
- ],
- "description": "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF.",
- "result": "failure",
- "messages": [
- "Following BFD peers are not configured, status is not up or remote disc is zero:\n{'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}}"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyNTP",
- "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": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyReachability",
- "categories": [
- "Connectivity"
- ],
- "description": "Test the network reachability to one or many destination IP(s).",
- "result": "error",
- "messages": [
- "ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyTelnetStatus",
- "categories": [
- "Security"
- ],
- "description": "Verifies if Telnet is disabled in the default VRF.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyEOSVersion",
- "categories": [
- "Software"
- ],
- "description": "Verifies the EOS version of the device.",
- "result": "failure",
- "messages": [
- "device is running version \"4.31.1F-34554157.4311F (engineering build)\" not in expected versions: ['4.25.4M', '4.26.1F']"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-SPINE1",
- "test": "VerifyHostname",
- "categories": [
- "Services"
- ],
- "description": "Verifies the hostname of a device.",
- "result": "failure",
- "messages": [
- "Expected `s1-spine1` as the hostname, but found `DC1-SPINE1` instead."
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyTacacsSourceIntf",
- "categories": [
- "AAA"
- ],
- "description": "Verifies TACACS source-interface for a specified VRF.",
- "result": "failure",
- "messages": [
- "Source-interface Management0 is not configured in VRF default"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyLLDPNeighbors",
- "categories": [
- "Connectivity"
- ],
- "description": "Verifies that the provided LLDP neighbors are connected properly.",
- "result": "failure",
- "messages": [
- "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-SPINE1_Ethernet1\n Ethernet2\n DC1-SPINE2_Ethernet1\nPort(s) not configured:\n Ethernet7"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyBGPPeerCount",
- "categories": [
- "BGP"
- ],
- "description": "Verifies the count of BGP peers.",
- "result": "failure",
- "messages": [
- "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 2, Actual: 1'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Expected: 3, Actual: 0'}}]"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifySTPMode",
- "categories": [
- "STP"
- ],
- "description": "Verifies the configured STP mode for a provided list of VLAN(s).",
- "result": "failure",
- "messages": [
- "Wrong STP mode configured for the following VLAN(s): [10, 20]"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifySnmpStatus",
- "categories": [
- "SNMP"
- ],
- "description": "Verifies if the SNMP agent is enabled.",
- "result": "failure",
- "messages": [
- "SNMP agent disabled in vrf default"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyRoutingTableEntry",
- "categories": [
- "Routing"
- ],
- "description": "Verifies that the provided routes are present in the routing table of a specified VRF.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyInterfaceUtilization",
- "categories": [
- "Interfaces"
- ],
- "description": "Verifies that the utilization of interfaces is below a certain threshold.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyMlagStatus",
- "categories": [
- "MLAG"
- ],
- "description": "Verifies the health status of the MLAG configuration.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyVxlan1Interface",
- "categories": [
- "VXLAN"
- ],
- "description": "Verifies the Vxlan1 interface status.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyBFDSpecificPeers",
- "categories": [
- "BFD"
- ],
- "description": "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF.",
- "result": "failure",
- "messages": [
- "Following BFD peers are not configured, status is not up or remote disc is zero:\n{'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}}"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyNTP",
- "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": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyReachability",
- "categories": [
- "Connectivity"
- ],
- "description": "Test the network reachability to one or many destination IP(s).",
- "result": "error",
- "messages": [
- "ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyTelnetStatus",
- "categories": [
- "Security"
- ],
- "description": "Verifies if Telnet is disabled in the default VRF.",
- "result": "success",
- "messages": [],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyEOSVersion",
- "categories": [
- "Software"
- ],
- "description": "Verifies the EOS version of the device.",
- "result": "failure",
- "messages": [
- "device is running version \"4.31.1F-34554157.4311F (engineering build)\" not in expected versions: ['4.25.4M', '4.26.1F']"
- ],
- "custom_field": null
- },
- {
- "name": "DC1-LEAF1A",
- "test": "VerifyHostname",
- "categories": [
- "Services"
- ],
- "description": "Verifies the hostname of a device.",
- "result": "failure",
- "messages": [
- "Expected `s1-spine1` as the hostname, but found `DC1-LEAF1A` instead."
- ],
- "custom_field": null
- }
+ {
+ "name": "s1-spine1",
+ "test": "VerifyMlagDualPrimary",
+ "categories": [
+ "mlag"
+ ],
+ "description": "Verifies the MLAG dual-primary detection parameters.",
+ "result": "failure",
+ "messages": [
+ "Dual-primary detection is disabled"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyHostname",
+ "categories": [
+ "services"
+ ],
+ "description": "Verifies the hostname of a device.",
+ "result": "failure",
+ "messages": [
+ "Incorrect Hostname - Expected: s1-spine1 Actual: leaf1-dc1"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyBGPAdvCommunities",
+ "categories": [
+ "bgp"
+ ],
+ "description": "Verifies that advertised communities are standard, extended and large for BGP IPv4 peer(s).",
+ "result": "error",
+ "messages": [
+ "show bgp neighbors vrf all has failed: The command is only supported in the multi-agent routing protocol model., The command is only supported in the multi-agent routing protocol model., The command is only supported in the multi-agent routing protocol model., The command is only supported in the multi-agent routing protocol model."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyStunClient",
+ "categories": [
+ "stun"
+ ],
+ "description": "(Deprecated) Verifies the translation for a source address on a STUN client.",
+ "result": "failure",
+ "messages": [
+ "Client 172.18.3.2 Port: 4500 - STUN client translation not found."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyBannerLogin",
+ "categories": [
+ "security"
+ ],
+ "description": "Verifies the login banner of a device.",
+ "result": "failure",
+ "messages": [
+ "Expected `# Copyright (c) 2023-2024 Arista Networks, Inc.\n# Use of this source code is governed by the Apache License 2.0\n# that can be found in the LICENSE file.\n` as the login banner, but found `` instead."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyISISNeighborState",
+ "categories": [
+ "isis"
+ ],
+ "description": "Verifies the health of IS-IS neighbors.",
+ "result": "skipped",
+ "messages": [
+ "IS-IS not configured"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyEOSVersion",
+ "categories": [
+ "software"
+ ],
+ "description": "Verifies the EOS version of the device.",
+ "result": "failure",
+ "messages": [
+ "EOS version mismatch - Actual: 4.31.0F-33804048.4310F (engineering build) not in Expected: 4.25.4M, 4.26.1F"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyTcamProfile",
+ "categories": [
+ "profiles"
+ ],
+ "description": "Verifies the device TCAM profile.",
+ "result": "skipped",
+ "messages": [
+ "VerifyTcamProfile test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyPathsHealth",
+ "categories": [
+ "path-selection"
+ ],
+ "description": "Verifies the path and telemetry state of all paths under router path-selection.",
+ "result": "skipped",
+ "messages": [
+ "VerifyPathsHealth test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyBannerMotd",
+ "categories": [
+ "security"
+ ],
+ "description": "Verifies the motd banner of a device.",
+ "result": "failure",
+ "messages": [
+ "Expected `# Copyright (c) 2023-2024 Arista Networks, Inc.\n# Use of this source code is governed by the Apache License 2.0\n# that can be found in the LICENSE file.\n` as the motd banner, but found `` instead."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyFieldNotice44Resolution",
+ "categories": [
+ "field notices"
+ ],
+ "description": "Verifies that the device is using the correct Aboot version per FN0044.",
+ "result": "skipped",
+ "messages": [
+ "VerifyFieldNotice44Resolution test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyLoggingHosts",
+ "categories": [
+ "logging"
+ ],
+ "description": "Verifies logging hosts (syslog servers) for a specified VRF.",
+ "result": "failure",
+ "messages": [
+ "Syslog servers 1.1.1.1, 2.2.2.2 are not configured in VRF default"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyAVTPathHealth",
+ "categories": [
+ "avt"
+ ],
+ "description": "Verifies the status of all AVT paths for all VRFs.",
+ "result": "skipped",
+ "messages": [
+ "VerifyAVTPathHealth test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyTemperature",
+ "categories": [
+ "hardware"
+ ],
+ "description": "Verifies if the device temperature is within acceptable limits.",
+ "result": "skipped",
+ "messages": [
+ "VerifyTemperature test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyNTPAssociations",
+ "categories": [
+ "system"
+ ],
+ "description": "Verifies the Network Time Protocol (NTP) associations.",
+ "result": "failure",
+ "messages": [
+ "NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Not configured",
+ "NTP Server: 2.2.2.2 Preferred: False Stratum: 2 - Not configured",
+ "NTP Server: 3.3.3.3 Preferred: False Stratum: 2 - Not configured"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyDynamicVlanSource",
+ "categories": [
+ "vlan"
+ ],
+ "description": "Verifies dynamic VLAN allocation for specified VLAN sources.",
+ "result": "failure",
+ "messages": [
+ "Dynamic VLAN source(s) exist but have no VLANs allocated: mlagsync"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyActiveCVXConnections",
+ "categories": [
+ "cvx"
+ ],
+ "description": "Verifies the number of active CVX Connections.",
+ "result": "error",
+ "messages": [
+ "show cvx connections brief has failed: Unavailable command (controller not ready) (at token 2: 'connections')"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyIPv4RouteNextHops",
+ "categories": [
+ "routing"
+ ],
+ "description": "Verifies the next-hops of the IPv4 prefixes.",
+ "result": "success",
+ "messages": [],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyVxlan1ConnSettings",
+ "categories": [
+ "vxlan"
+ ],
+ "description": "Verifies the interface vxlan1 source interface and UDP port.",
+ "result": "success",
+ "messages": [],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyStunClientTranslation",
+ "categories": [
+ "stun"
+ ],
+ "description": "Verifies the translation for a source address on a STUN client.",
+ "result": "failure",
+ "messages": [
+ "Client 172.18.3.2 Port: 4500 - STUN client translation not found.",
+ "Client 100.64.3.2 Port: 4500 - STUN client translation not found."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyPtpGMStatus",
+ "categories": [
+ "ptp"
+ ],
+ "description": "Verifies that the device is locked to a valid PTP Grandmaster.",
+ "result": "skipped",
+ "messages": [
+ "VerifyPtpGMStatus test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyRunningConfigDiffs",
+ "categories": [
+ "configuration"
+ ],
+ "description": "Verifies there is no difference between the running-config and the startup-config.",
+ "result": "success",
+ "messages": [],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyBFDPeersHealth",
+ "categories": [
+ "bfd"
+ ],
+ "description": "Verifies the health of IPv4 BFD peers across all VRFs.",
+ "result": "failure",
+ "messages": [
+ "No IPv4 BFD peers are configured for any VRF."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyIPProxyARP",
+ "categories": [
+ "interfaces"
+ ],
+ "description": "Verifies if Proxy ARP is enabled.",
+ "result": "failure",
+ "messages": [
+ "Interface: Ethernet1 - Proxy-ARP disabled",
+ "Interface: Ethernet2 - Proxy-ARP disabled"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifySnmpContact",
+ "categories": [
+ "snmp"
+ ],
+ "description": "Verifies the SNMP contact of a device.",
+ "result": "failure",
+ "messages": [
+ "SNMP contact is not configured."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyLLDPNeighbors",
+ "categories": [
+ "connectivity"
+ ],
+ "description": "Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors.",
+ "result": "failure",
+ "messages": [
+ "Port: Ethernet1 Neighbor: DC1-SPINE1 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: spine1-dc1.fun.aristanetworks.com/Ethernet3",
+ "Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: spine2-dc1.fun.aristanetworks.com/Ethernet3"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyAcctConsoleMethods",
+ "categories": [
+ "aaa"
+ ],
+ "description": "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x).",
+ "result": "failure",
+ "messages": [
+ "AAA console accounting is not configured for commands, exec, system, dot1x"
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyOSPFMaxLSA",
+ "categories": [
+ "ospf"
+ ],
+ "description": "Verifies all OSPF instances did not cross the maximum LSA threshold.",
+ "result": "skipped",
+ "messages": [
+ "No OSPF instance found."
+ ],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifySTPBlockedPorts",
+ "categories": [
+ "stp"
+ ],
+ "description": "Verifies there is no STP blocked ports.",
+ "result": "success",
+ "messages": [],
+ "custom_field": null
+ },
+ {
+ "name": "s1-spine1",
+ "test": "VerifyLANZ",
+ "categories": [
+ "lanz"
+ ],
+ "description": "Verifies if LANZ is enabled.",
+ "result": "skipped",
+ "messages": [
+ "VerifyLANZ test is not supported on cEOSLab."
+ ],
+ "custom_field": null
+ }
]
diff --git a/tests/units/result_manager/test_models.py b/tests/units/result_manager/test_models.py
index 0561dff..1846af4 100644
--- a/tests/units/result_manager/test_models.py
+++ b/tests/units/result_manager/test_models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 Result Manager models unit tests."""
diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py
index 57a8e2f..17212a9 100644
--- a/tests/units/test_catalog.py
+++ b/tests/units/test_catalog.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.device.py."""
diff --git a/tests/units/test_custom_types.py b/tests/units/test_custom_types.py
index 95c5234..c0f3f3a 100644
--- a/tests/units/test_custom_types.py
+++ b/tests/units/test_custom_types.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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`.
@@ -15,12 +15,7 @@ import re
import pytest
from anta.custom_types import (
- REGEX_BGP_IPV4_MPLS_VPN,
- REGEX_BGP_IPV4_UNICAST,
REGEX_TYPE_PORTCHANNEL,
- REGEXP_BGP_IPV4_MPLS_LABELS,
- REGEXP_BGP_L2VPN_AFI,
- REGEXP_EOS_BLACKLIST_CMDS,
REGEXP_INTERFACE_ID,
REGEXP_PATH_MARKERS,
REGEXP_TYPE_EOS_INTERFACE,
@@ -30,6 +25,7 @@ from anta.custom_types import (
bgp_multiprotocol_capabilities_abbreviations,
interface_autocomplete,
interface_case_sensitivity,
+ snmp_v3_prefix,
validate_regex,
)
@@ -51,40 +47,6 @@ def test_regexp_path_markers() -> 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}")
@@ -174,35 +136,6 @@ def test_regexp_type_hostname() -> 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
# ------------------------------------------------------------------------------
@@ -219,6 +152,7 @@ def test_interface_autocomplete_success() -> None:
assert interface_autocomplete("lo4") == "Loopback4"
assert interface_autocomplete("Po1000") == "Port-Channel1000"
assert interface_autocomplete("Po 1000") == "Port-Channel1000"
+ assert interface_autocomplete("Vl1000") == "Vlan1000"
def test_interface_autocomplete_no_alias() -> None:
@@ -238,13 +172,29 @@ def test_interface_autocomplete_failure() -> None:
("str_input", "expected_output"),
[
pytest.param("L2VPNEVPN", "l2VpnEvpn", id="l2VpnEvpn"),
- pytest.param("ipv4-mplsLabels", "ipv4MplsLabels", id="ipv4MplsLabels"),
+ pytest.param("IPv4 Labeled Unicast", "ipv4MplsLabels", id="ipv4MplsLabels"),
pytest.param("ipv4-mpls-vpn", "ipv4MplsVpn", id="ipv4MplsVpn"),
- pytest.param("ipv4-unicast", "ipv4Unicast", id="ipv4Unicast"),
- pytest.param("BLAH", "BLAH", id="unmatched"),
+ pytest.param("ipv4_unicast", "ipv4Unicast", id="ipv4Unicast"),
+ pytest.param("ipv4 Mvpn", "ipv4Mvpn", id="ipv4Mvpn"),
+ pytest.param("ipv4_Flow-Spec Vpn", "ipv4FlowSpecVpn", id="ipv4FlowSpecVpn"),
+ pytest.param("Dynamic-Path-Selection", "dps", id="dps"),
+ pytest.param("ipv6unicast", "ipv6Unicast", id="ipv6Unicast"),
+ pytest.param("IPv4-Multicast", "ipv4Multicast", id="ipv4Multicast"),
+ pytest.param("IPv6_multicast", "ipv6Multicast", id="ipv6Multicast"),
+ pytest.param("ipv6_Mpls-Labels", "ipv6MplsLabels", id="ipv6MplsLabels"),
+ pytest.param("IPv4_SR_TE", "ipv4SrTe", id="ipv4SrTe"),
+ pytest.param("iPv6-sR-tE", "ipv6SrTe", id="ipv6SrTe"),
+ pytest.param("ipv6_mpls-vpn", "ipv6MplsVpn", id="ipv6MplsVpn"),
+ pytest.param("IPv4 Flow-spec", "ipv4FlowSpec", id="ipv4FlowSpec"),
+ pytest.param("IPv6Flow_spec", "ipv6FlowSpec", id="ipv6FlowSpec"),
+ pytest.param("ipv6 Flow-Spec Vpn", "ipv6FlowSpecVpn", id="ipv6FlowSpecVpn"),
+ pytest.param("L2VPN VPLS", "l2VpnVpls", id="l2VpnVpls"),
+ pytest.param("link-state", "linkState", id="linkState"),
+ pytest.param("RT_Membership", "rtMembership", id="rtMembership"),
+ pytest.param("ipv4-RT_Membership", "rtMembership", id="rtMembership"),
],
)
-def test_bgp_multiprotocol_capabilities_abbreviationsh(str_input: str, expected_output: str) -> None:
+def test_bgp_multiprotocol_capabilities_abbreviations(str_input: str, expected_output: str) -> None:
"""Test bgp_multiprotocol_capabilities_abbreviations."""
assert bgp_multiprotocol_capabilities_abbreviations(str_input) == expected_output
@@ -286,11 +236,7 @@ def test_interface_case_sensitivity_uppercase() -> None:
@pytest.mark.parametrize(
"str_input",
[
- REGEX_BGP_IPV4_MPLS_VPN,
- REGEX_BGP_IPV4_UNICAST,
REGEX_TYPE_PORTCHANNEL,
- REGEXP_BGP_IPV4_MPLS_LABELS,
- REGEXP_BGP_L2VPN_AFI,
REGEXP_INTERFACE_ID,
REGEXP_PATH_MARKERS,
REGEXP_TYPE_EOS_INTERFACE,
@@ -314,3 +260,10 @@ def test_validate_regex_invalid(str_input: str, error: str) -> None:
"""Test validate_regex with invalid regex."""
with pytest.raises(ValueError, match=error):
validate_regex(str_input)
+
+
+def test_snmp_v3_prefix_valid_input() -> None:
+ """Test snmp_v3_prefix with valid authentication type."""
+ assert snmp_v3_prefix("auth") == "v3Auth"
+ assert snmp_v3_prefix("noauth") == "v3NoAuth"
+ assert snmp_v3_prefix("priv") == "v3Priv"
diff --git a/tests/units/test_decorators.py b/tests/units/test_decorators.py
index c267df1..a857b2c 100644
--- a/tests/units/test_decorators.py
+++ b/tests/units/test_decorators.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.decorators.py."""
diff --git a/tests/units/test_device.py b/tests/units/test_device.py
index 17669df..e65eeb2 100644
--- a/tests/units/test_device.py
+++ b/tests/units/test_device.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.device.py."""
@@ -589,11 +589,11 @@ class TestAntaDevice:
if expected["cache_hit"] is True:
assert cmd.output == cached_output
assert current_cached_data == cached_output
- assert device.cache.hit_miss_ratio["hits"] == 2
+ assert device.cache.stats["hits"] == 2
else:
assert cmd.output == COMMAND_OUTPUT
assert current_cached_data == COMMAND_OUTPUT
- assert device.cache.hit_miss_ratio["hits"] == 1
+ assert device.cache.stats["hits"] == 1
else: # command is not allowed to use cache
device._collect.assert_called_once_with(command=cmd, collection_id=None) # type: ignore[attr-defined]
assert cmd.output == COMMAND_OUTPUT
diff --git a/tests/units/test_logger.py b/tests/units/test_logger.py
index 706591f..a8f0bc7 100644
--- a/tests/units/test_logger.py
+++ b/tests/units/test_logger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.logger."""
@@ -6,11 +6,54 @@
from __future__ import annotations
import logging
+from pathlib import Path
from unittest.mock import patch
import pytest
-from anta.logger import anta_log_exception, exc_to_str, tb_to_str
+from anta.logger import Log, LogLevel, _get_file_handler, _get_rich_handler, anta_log_exception, exc_to_str, setup_logging, tb_to_str
+
+
+@pytest.mark.parametrize(
+ ("level", "path", "debug_value"),
+ [
+ pytest.param(Log.INFO, None, False, id="INFO no file"),
+ pytest.param(Log.DEBUG, None, False, id="DEBUG no file"),
+ pytest.param(Log.INFO, Path("/tmp/file.log"), False, id="INFO file"),
+ pytest.param(Log.DEBUG, Path("/tmp/file.log"), False, id="DEBUG file"),
+ pytest.param(Log.INFO, None, True, id="INFO no file __DEBUG__ set"),
+ pytest.param(Log.DEBUG, None, True, id="INFO no file __DEBUG__ set"),
+ ],
+)
+def test_setup_logging(level: LogLevel, path: Path | None, debug_value: bool) -> None:
+ """Test setup_logging."""
+ # Clean up any logger on root
+ root = logging.getLogger()
+ if root.hasHandlers():
+ root.handlers = []
+
+ with patch("anta.logger.__DEBUG__", new=debug_value):
+ setup_logging(level, path)
+
+ rich_handler = _get_rich_handler(root)
+ assert rich_handler is not None
+
+ # When __DEBUG__ is True, the log level is overwritten to DEBUG
+ if debug_value:
+ assert root.level == logging.DEBUG
+ if path is not None:
+ assert rich_handler.level == logging.INFO
+
+ if path is not None:
+ assert _get_file_handler(root, path) is not None
+ expected_handlers = 2
+ else:
+ expected_handlers = 1
+ assert len(root.handlers) == expected_handlers
+
+ # Check idempotency
+ setup_logging(level, path)
+ assert len(root.handlers) == expected_handlers
@pytest.mark.parametrize(
diff --git a/tests/units/test_models.py b/tests/units/test_models.py
index d12d859..e7b1db9 100644
--- a/tests/units/test_models.py
+++ b/tests/units/test_models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.models.py."""
@@ -515,7 +515,7 @@ ANTATEST_DATA: list[dict[str, Any]] = [
},
]
-BLACKLIST_COMMANDS_PARAMS = ["reload", "reload --force", "write", "wr mem"]
+BLACKLIST_COMMANDS_PARAMS = ["reload", "reload now", "reload --force", "write", "wr mem", "write memory", "conf t", "configure terminal", "configure session"]
class TestAntaTest:
diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py
index 23f4102..1b9c40c 100644
--- a/tests/units/test_runner.py
+++ b/tests/units/test_runner.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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 anta.runner.py."""
@@ -67,7 +67,7 @@ async def test_no_selected_device(caplog: pytest.LogCaptureFixture, inventory: A
caplog.set_level(logging.WARNING)
manager = ResultManager()
await main(manager, inventory, FAKE_CATALOG, tags=tags, devices=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 ""}'
+ 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 ''}"
assert msg in caplog.messages
diff --git a/tests/units/test_tools.py b/tests/units/test_tools.py
index b1f96a5..396b0c8 100644
--- a/tests/units/test_tools.py
+++ b/tests/units/test_tools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Copyright (c) 2023-2025 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.tools`."""