summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-03-28 06:11:39 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-03-28 06:11:39 +0000
commit1fd6a618b60d7168fd8f37585d5d39d22d775afd (patch)
treefbc6d0c213b8acdd0a31deafe5c5fc0d05a3a312 /tests
parentInitial commit. (diff)
downloadanta-1fd6a618b60d7168fd8f37585d5d39d22d775afd.tar.xz
anta-1fd6a618b60d7168fd8f37585d5d39d22d775afd.zip
Adding upstream version 0.13.0.upstream/0.13.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'tests')
-rw-r--r--tests/__init__.py3
-rw-r--r--tests/conftest.py55
-rw-r--r--tests/data/__init__.py3
-rw-r--r--tests/data/ansible_inventory.yml47
-rw-r--r--tests/data/empty0
-rw-r--r--tests/data/empty_ansible_inventory.yml1
-rw-r--r--tests/data/expected_anta_inventory.yml16
-rw-r--r--tests/data/json_data.py258
-rw-r--r--tests/data/syntax_error.py7
-rw-r--r--tests/data/template.j23
-rw-r--r--tests/data/test_catalog.yml5
-rw-r--r--tests/data/test_catalog_not_a_list.yml2
-rw-r--r--tests/data/test_catalog_test_definition_multiple_dicts.yml9
-rw-r--r--tests/data/test_catalog_test_definition_not_a_dict.yml3
-rw-r--r--tests/data/test_catalog_with_syntax_error_module.yml2
-rw-r--r--tests/data/test_catalog_with_tags.yml28
-rw-r--r--tests/data/test_catalog_with_undefined_module.yml3
-rw-r--r--tests/data/test_catalog_with_undefined_module_nested.yml4
-rw-r--r--tests/data/test_catalog_with_undefined_tests.yml3
-rw-r--r--tests/data/test_catalog_wrong_type.yml1
-rw-r--r--tests/data/test_empty_catalog.yml0
-rw-r--r--tests/data/test_inventory.yml12
-rw-r--r--tests/data/test_snapshot_commands.yml8
-rw-r--r--tests/data/toto.yml16
-rw-r--r--tests/lib/__init__.py3
-rw-r--r--tests/lib/anta.py34
-rw-r--r--tests/lib/fixture.py242
-rw-r--r--tests/lib/utils.py49
-rw-r--r--tests/mock_data/show_ntp_status_text_synchronised.out1
-rw-r--r--tests/mock_data/show_uptime_json_1000000.out1
-rw-r--r--tests/mock_data/show_version_json_4.27.1.1F.out1
-rw-r--r--tests/units/__init__.py3
-rw-r--r--tests/units/anta_tests/README.md7
-rw-r--r--tests/units/anta_tests/__init__.py3
-rw-r--r--tests/units/anta_tests/routing/__init__.py3
-rw-r--r--tests/units/anta_tests/routing/test_bgp.py3385
-rw-r--r--tests/units/anta_tests/routing/test_generic.py230
-rw-r--r--tests/units/anta_tests/routing/test_ospf.py298
-rw-r--r--tests/units/anta_tests/test_aaa.py516
-rw-r--r--tests/units/anta_tests/test_bfd.py523
-rw-r--r--tests/units/anta_tests/test_configuration.py35
-rw-r--r--tests/units/anta_tests/test_connectivity.py369
-rw-r--r--tests/units/anta_tests/test_field_notices.py291
-rw-r--r--tests/units/anta_tests/test_greent.py47
-rw-r--r--tests/units/anta_tests/test_hardware.py918
-rw-r--r--tests/units/anta_tests/test_interfaces.py1411
-rw-r--r--tests/units/anta_tests/test_lanz.py27
-rw-r--r--tests/units/anta_tests/test_logging.py254
-rw-r--r--tests/units/anta_tests/test_mlag.py343
-rw-r--r--tests/units/anta_tests/test_multicast.py175
-rw-r--r--tests/units/anta_tests/test_profiles.py47
-rw-r--r--tests/units/anta_tests/test_ptp.py42
-rw-r--r--tests/units/anta_tests/test_security.py900
-rw-r--r--tests/units/anta_tests/test_services.py218
-rw-r--r--tests/units/anta_tests/test_snmp.py128
-rw-r--r--tests/units/anta_tests/test_software.py101
-rw-r--r--tests/units/anta_tests/test_stp.py328
-rw-r--r--tests/units/anta_tests/test_system.py283
-rw-r--r--tests/units/anta_tests/test_vlan.py37
-rw-r--r--tests/units/anta_tests/test_vxlan.py365
-rw-r--r--tests/units/cli/__init__.py3
-rw-r--r--tests/units/cli/check/__init__.py3
-rw-r--r--tests/units/cli/check/test__init__.py30
-rw-r--r--tests/units/cli/check/test_commands.py37
-rw-r--r--tests/units/cli/debug/__init__.py3
-rw-r--r--tests/units/cli/debug/test__init__.py30
-rw-r--r--tests/units/cli/debug/test_commands.py60
-rw-r--r--tests/units/cli/exec/__init__.py3
-rw-r--r--tests/units/cli/exec/test__init__.py30
-rw-r--r--tests/units/cli/exec/test_commands.py125
-rw-r--r--tests/units/cli/exec/test_utils.py134
-rw-r--r--tests/units/cli/get/__init__.py3
-rw-r--r--tests/units/cli/get/test__init__.py30
-rw-r--r--tests/units/cli/get/test_commands.py204
-rw-r--r--tests/units/cli/get/test_utils.py115
-rw-r--r--tests/units/cli/nrfu/__init__.py3
-rw-r--r--tests/units/cli/nrfu/test__init__.py111
-rw-r--r--tests/units/cli/nrfu/test_commands.py97
-rw-r--r--tests/units/cli/test__init__.py58
-rw-r--r--tests/units/inventory/__init__.py3
-rw-r--r--tests/units/inventory/test_inventory.py81
-rw-r--r--tests/units/inventory/test_models.py393
-rw-r--r--tests/units/reporter/__init__.py3
-rw-r--r--tests/units/reporter/test__init__.py193
-rw-r--r--tests/units/result_manager/__init__.py3
-rw-r--r--tests/units/result_manager/test__init__.py204
-rw-r--r--tests/units/result_manager/test_models.py57
-rw-r--r--tests/units/test_catalog.py311
-rw-r--r--tests/units/test_device.py777
-rw-r--r--tests/units/test_logger.py80
-rw-r--r--tests/units/test_models.py472
-rw-r--r--tests/units/test_runner.py82
-rw-r--r--tests/units/tools/__init__.py3
-rw-r--r--tests/units/tools/test_get_dict_superset.py149
-rw-r--r--tests/units/tools/test_get_item.py72
-rw-r--r--tests/units/tools/test_get_value.py50
-rw-r--r--tests/units/tools/test_misc.py38
-rw-r--r--tests/units/tools/test_utils.py57
98 files changed, 16214 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..5a40c24
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,55 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+conftest.py - used to store anta specific fixtures used for tests
+"""
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+if TYPE_CHECKING:
+ from pytest import Metafunc
+
+# Load fixtures from dedicated file tests/lib/fixture.py
+# As well as pytest_asyncio plugin to test co-routines
+pytest_plugins = [
+ "tests.lib.fixture",
+ "pytest_asyncio",
+]
+
+# Enable nice assert messages
+# https://docs.pytest.org/en/7.1.x/how-to/writing_plugins.html#assertion-rewriting
+pytest.register_assert_rewrite("tests.lib.anta")
+
+# Placeholder to disable logging of some external libs
+for _ in ("asyncio", "httpx"):
+ logging.getLogger(_).setLevel(logging.CRITICAL)
+
+
+def build_test_id(val: dict[str, Any]) -> str:
+ """
+ build id for a unit test of an AntaTest subclass
+
+ {
+ "name": "meaniful test name",
+ "test": <AntaTest instance>,
+ ...
+ }
+ """
+ return f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}"
+
+
+def pytest_generate_tests(metafunc: Metafunc) -> None:
+ """
+ This function is called during test collection.
+ It will parametrize test cases based on the `DATA` data structure defined in `tests.units.anta_tests` modules.
+ See `tests/units/anta_tests/README.md` for more information on how to use it.
+ Test IDs are generated using the `build_test_id` function above.
+ """
+ if "tests.units.anta_tests" in metafunc.module.__package__:
+ # This is a unit test for an AntaTest subclass
+ metafunc.parametrize("data", metafunc.module.DATA, ids=build_test_id)
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/data/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/data/ansible_inventory.yml b/tests/data/ansible_inventory.yml
new file mode 100644
index 0000000..a958505
--- /dev/null
+++ b/tests/data/ansible_inventory.yml
@@ -0,0 +1,47 @@
+---
+all:
+ children:
+ cv_servers:
+ hosts:
+ cv_atd1:
+ ansible_host: 10.73.1.238
+ ansible_user: tom
+ ansible_password: arista123
+ cv_collection: v3
+ ATD_LAB:
+ vars:
+ ansible_user: arista
+ ansible_ssh_pass: arista
+ children:
+ ATD_FABRIC:
+ children:
+ ATD_SPINES:
+ vars:
+ type: spine
+ hosts:
+ spine1:
+ ansible_host: 192.168.0.10
+ spine2:
+ ansible_host: 192.168.0.11
+ ATD_LEAFS:
+ vars:
+ type: l3leaf
+ children:
+ pod1:
+ hosts:
+ leaf1:
+ ansible_host: 192.168.0.12
+ leaf2:
+ ansible_host: 192.168.0.13
+ pod2:
+ hosts:
+ leaf3:
+ ansible_host: 192.168.0.14
+ leaf4:
+ ansible_host: 192.168.0.15
+ ATD_TENANTS_NETWORKS:
+ children:
+ ATD_LEAFS:
+ ATD_SERVERS:
+ children:
+ ATD_LEAFS:
diff --git a/tests/data/empty b/tests/data/empty
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/data/empty
diff --git a/tests/data/empty_ansible_inventory.yml b/tests/data/empty_ansible_inventory.yml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/tests/data/empty_ansible_inventory.yml
@@ -0,0 +1 @@
+---
diff --git a/tests/data/expected_anta_inventory.yml b/tests/data/expected_anta_inventory.yml
new file mode 100644
index 0000000..c0f92cb
--- /dev/null
+++ b/tests/data/expected_anta_inventory.yml
@@ -0,0 +1,16 @@
+anta_inventory:
+ hosts:
+ - host: 10.73.1.238
+ name: cv_atd1
+ - host: 192.168.0.10
+ name: spine1
+ - host: 192.168.0.11
+ name: spine2
+ - host: 192.168.0.12
+ name: leaf1
+ - host: 192.168.0.13
+ name: leaf2
+ - host: 192.168.0.14
+ name: leaf3
+ - host: 192.168.0.15
+ name: leaf4
diff --git a/tests/data/json_data.py b/tests/data/json_data.py
new file mode 100644
index 0000000..ad2c9ed
--- /dev/null
+++ b/tests/data/json_data.py
@@ -0,0 +1,258 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+# pylint: skip-file
+
+INVENTORY_MODEL_HOST_VALID = [
+ {"name": "validIPv4", "input": "1.1.1.1", "expected_result": "valid"},
+ {
+ "name": "validIPv6",
+ "input": "fe80::cc62:a9ff:feef:932a",
+ },
+]
+
+INVENTORY_MODEL_HOST_INVALID = [
+ {
+ "name": "invalidIPv4_with_netmask",
+ "input": "1.1.1.1/32",
+ },
+ {
+ "name": "invalidIPv6_with_netmask",
+ "input": "fe80::cc62:a9ff:feef:932a/128",
+ },
+ {"name": "invalidHost_format", "input": "@", "expected_result": "invalid"},
+ {
+ "name": "invalidIPv6_format",
+ "input": "fe80::cc62:a9ff:feef:",
+ },
+]
+
+INVENTORY_MODEL_HOST_CACHE = [
+ {"name": "Host cache default", "input": {"host": "1.1.1.1"}, "expected_result": False},
+ {"name": "Host cache enabled", "input": {"host": "1.1.1.1", "disable_cache": False}, "expected_result": False},
+ {"name": "Host cache disabled", "input": {"host": "1.1.1.1", "disable_cache": True}, "expected_result": True},
+]
+
+INVENTORY_MODEL_NETWORK_VALID = [
+ {"name": "ValidIPv4_Subnet", "input": "1.1.1.0/24", "expected_result": "valid"},
+ {"name": "ValidIPv6_Subnet", "input": "2001:db8::/32", "expected_result": "valid"},
+]
+
+INVENTORY_MODEL_NETWORK_INVALID = [
+ {"name": "ValidIPv4_Subnet", "input": "1.1.1.0/17", "expected_result": "invalid"},
+ {
+ "name": "InvalidIPv6_Subnet",
+ "input": "2001:db8::/16",
+ "expected_result": "invalid",
+ },
+]
+
+INVENTORY_MODEL_NETWORK_CACHE = [
+ {"name": "Network cache default", "input": {"network": "1.1.1.0/24"}, "expected_result": False},
+ {"name": "Network cache enabled", "input": {"network": "1.1.1.0/24", "disable_cache": False}, "expected_result": False},
+ {"name": "Network cache disabled", "input": {"network": "1.1.1.0/24", "disable_cache": True}, "expected_result": True},
+]
+
+INVENTORY_MODEL_RANGE_VALID = [
+ {
+ "name": "ValidIPv4_Range",
+ "input": {"start": "10.1.0.1", "end": "10.1.0.10"},
+ "expected_result": "valid",
+ },
+]
+
+INVENTORY_MODEL_RANGE_INVALID = [
+ {
+ "name": "InvalidIPv4_Range_name",
+ "input": {"start": "toto", "end": "10.1.0.1"},
+ "expected_result": "invalid",
+ },
+]
+
+INVENTORY_MODEL_RANGE_CACHE = [
+ {"name": "Range cache default", "input": {"start": "1.1.1.1", "end": "1.1.1.10"}, "expected_result": False},
+ {"name": "Range cache enabled", "input": {"start": "1.1.1.1", "end": "1.1.1.10", "disable_cache": False}, "expected_result": False},
+ {"name": "Range cache disabled", "input": {"start": "1.1.1.1", "end": "1.1.1.10", "disable_cache": True}, "expected_result": True},
+]
+
+INVENTORY_MODEL_VALID = [
+ {
+ "name": "Valid_Host_Only",
+ "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}]},
+ "expected_result": "valid",
+ },
+ {
+ "name": "Valid_Networks_Only",
+ "input": {"networks": [{"network": "192.168.0.0/16"}, {"network": "192.168.1.0/24"}]},
+ "expected_result": "valid",
+ },
+ {
+ "name": "Valid_Ranges_Only",
+ "input": {
+ "ranges": [
+ {"start": "10.1.0.1", "end": "10.1.0.10"},
+ {"start": "10.2.0.1", "end": "10.2.1.10"},
+ ]
+ },
+ "expected_result": "valid",
+ },
+]
+
+INVENTORY_MODEL_INVALID = [
+ {
+ "name": "Host_with_Invalid_entry",
+ "input": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2/32"}]},
+ "expected_result": "invalid",
+ },
+]
+
+INVENTORY_DEVICE_MODEL_VALID = [
+ {
+ "name": "Valid_Inventory",
+ "input": [{"host": "1.1.1.1", "username": "arista", "password": "arista123!"}, {"host": "1.1.1.2", "username": "arista", "password": "arista123!"}],
+ "expected_result": "valid",
+ },
+]
+
+INVENTORY_DEVICE_MODEL_INVALID = [
+ {
+ "name": "Invalid_Inventory",
+ "input": [{"host": "1.1.1.1", "password": "arista123!"}, {"host": "1.1.1.1", "username": "arista"}],
+ "expected_result": "invalid",
+ },
+]
+
+ANTA_INVENTORY_TESTS_VALID = [
+ {
+ "name": "ValidInventory_with_host_only",
+ "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17"}, {"host": "192.168.0.2"}, {"host": "my.awesome.host.com"}]}},
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "192.168.0.17",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 2,
+ },
+ },
+ {
+ "name": "ValidInventory_with_networks_only",
+ "input": {"anta_inventory": {"networks": [{"network": "192.168.0.0/24"}]}},
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "192.168.0.1",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 256,
+ },
+ },
+ {
+ "name": "ValidInventory_with_ranges_only",
+ "input": {
+ "anta_inventory": {
+ "ranges": [
+ {"start": "10.0.0.1", "end": "10.0.0.11"},
+ {"start": "10.0.0.101", "end": "10.0.0.111"},
+ ]
+ }
+ },
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "10.0.0.10",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 22,
+ },
+ },
+ {
+ "name": "ValidInventory_with_host_port",
+ "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "port": 443}, {"host": "192.168.0.2", "port": 80}]}},
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "192.168.0.17",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 2,
+ },
+ },
+ {
+ "name": "ValidInventory_with_host_tags",
+ "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17", "tags": ["leaf"]}, {"host": "192.168.0.2", "tags": ["spine"]}]}},
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "192.168.0.17",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 2,
+ },
+ },
+ {
+ "name": "ValidInventory_with_networks_tags",
+ "input": {"anta_inventory": {"networks": [{"network": "192.168.0.0/24", "tags": ["leaf"]}]}},
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "192.168.0.1",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 256,
+ },
+ },
+ {
+ "name": "ValidInventory_with_ranges_tags",
+ "input": {
+ "anta_inventory": {
+ "ranges": [
+ {"start": "10.0.0.1", "end": "10.0.0.11", "tags": ["leaf"]},
+ {"start": "10.0.0.101", "end": "10.0.0.111", "tags": ["spine"]},
+ ]
+ }
+ },
+ "expected_result": "valid",
+ "parameters": {
+ "ipaddress_in_scope": "10.0.0.10",
+ "ipaddress_out_of_scope": "192.168.1.1",
+ "nb_hosts": 22,
+ },
+ },
+]
+
+ANTA_INVENTORY_TESTS_INVALID = [
+ {
+ "name": "InvalidInventory_with_host_only",
+ "input": {"anta_inventory": {"hosts": [{"host": "192.168.0.17/32"}, {"host": "192.168.0.2"}]}},
+ "expected_result": "invalid",
+ },
+ {
+ "name": "InvalidInventory_wrong_network_bits",
+ "input": {"anta_inventory": {"networks": [{"network": "192.168.42.0/8"}]}},
+ "expected_result": "invalid",
+ },
+ {
+ "name": "InvalidInventory_wrong_network",
+ "input": {"anta_inventory": {"networks": [{"network": "toto"}]}},
+ "expected_result": "invalid",
+ },
+ {
+ "name": "InvalidInventory_wrong_range",
+ "input": {"anta_inventory": {"ranges": [{"start": "toto", "end": "192.168.42.42"}]}},
+ "expected_result": "invalid",
+ },
+ {
+ "name": "InvalidInventory_wrong_range_type_mismatch",
+ "input": {"anta_inventory": {"ranges": [{"start": "fe80::cafe", "end": "192.168.42.42"}]}},
+ "expected_result": "invalid",
+ },
+ {
+ "name": "Invalid_Root_Key",
+ "input": {
+ "inventory": {
+ "ranges": [
+ {"start": "10.0.0.1", "end": "10.0.0.11"},
+ {"start": "10.0.0.100", "end": "10.0.0.111"},
+ ]
+ }
+ },
+ "expected_result": "invalid",
+ },
+]
+
+TEST_RESULT_SET_STATUS = [
+ {"name": "set_success", "target": "success", "message": "success"},
+ {"name": "set_error", "target": "error", "message": "error"},
+ {"name": "set_failure", "target": "failure", "message": "failure"},
+ {"name": "set_skipped", "target": "skipped", "message": "skipped"},
+ {"name": "set_unset", "target": "unset", "message": "unset"},
+]
diff --git a/tests/data/syntax_error.py b/tests/data/syntax_error.py
new file mode 100644
index 0000000..051ef33
--- /dev/null
+++ b/tests/data/syntax_error.py
@@ -0,0 +1,7 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+# pylint: skip-file
+# flake8: noqa
+# type: ignore
+typo
diff --git a/tests/data/template.j2 b/tests/data/template.j2
new file mode 100644
index 0000000..e8820fe
--- /dev/null
+++ b/tests/data/template.j2
@@ -0,0 +1,3 @@
+{% for d in data %}
+* {{ d.test }} is [green]{{ d.result | upper}}[/green] for {{ d.name }}
+{% endfor %}
diff --git a/tests/data/test_catalog.yml b/tests/data/test_catalog.yml
new file mode 100644
index 0000000..c5b55b0
--- /dev/null
+++ b/tests/data/test_catalog.yml
@@ -0,0 +1,5 @@
+---
+anta.tests.software:
+ - VerifyEOSVersion:
+ versions:
+ - 4.31.1F
diff --git a/tests/data/test_catalog_not_a_list.yml b/tests/data/test_catalog_not_a_list.yml
new file mode 100644
index 0000000..d8c4297
--- /dev/null
+++ b/tests/data/test_catalog_not_a_list.yml
@@ -0,0 +1,2 @@
+---
+anta.tests.configuration: true
diff --git a/tests/data/test_catalog_test_definition_multiple_dicts.yml b/tests/data/test_catalog_test_definition_multiple_dicts.yml
new file mode 100644
index 0000000..9287ee6
--- /dev/null
+++ b/tests/data/test_catalog_test_definition_multiple_dicts.yml
@@ -0,0 +1,9 @@
+---
+anta.tests.software:
+ - VerifyEOSVersion:
+ versions:
+ - 4.25.4M
+ - 4.26.1F
+ VerifyTerminAttrVersion:
+ versions:
+ - 4.25.4M
diff --git a/tests/data/test_catalog_test_definition_not_a_dict.yml b/tests/data/test_catalog_test_definition_not_a_dict.yml
new file mode 100644
index 0000000..052ad26
--- /dev/null
+++ b/tests/data/test_catalog_test_definition_not_a_dict.yml
@@ -0,0 +1,3 @@
+---
+anta.tests.software:
+ - VerifyEOSVersion
diff --git a/tests/data/test_catalog_with_syntax_error_module.yml b/tests/data/test_catalog_with_syntax_error_module.yml
new file mode 100644
index 0000000..8b3e00a
--- /dev/null
+++ b/tests/data/test_catalog_with_syntax_error_module.yml
@@ -0,0 +1,2 @@
+---
+tests.data.syntax_error:
diff --git a/tests/data/test_catalog_with_tags.yml b/tests/data/test_catalog_with_tags.yml
new file mode 100644
index 0000000..0c8f5f6
--- /dev/null
+++ b/tests/data/test_catalog_with_tags.yml
@@ -0,0 +1,28 @@
+---
+anta.tests.system:
+ - VerifyUptime:
+ minimum: 10
+ filters:
+ tags: ['fabric']
+ - VerifyReloadCause:
+ filters:
+ tags: ['leaf', 'spine']
+ - VerifyCoredump:
+ - VerifyAgentLogs:
+ - VerifyCPUUtilization:
+ filters:
+ tags: ['leaf']
+ - VerifyMemoryUtilization:
+ filters:
+ tags: ['testdevice']
+ - VerifyFileSystemUtilization:
+ - VerifyNTP:
+
+anta.tests.mlag:
+ - VerifyMlagStatus:
+
+anta.tests.interfaces:
+ - VerifyL3MTU:
+ mtu: 1500
+ filters:
+ tags: ['demo']
diff --git a/tests/data/test_catalog_with_undefined_module.yml b/tests/data/test_catalog_with_undefined_module.yml
new file mode 100644
index 0000000..f2e116b
--- /dev/null
+++ b/tests/data/test_catalog_with_undefined_module.yml
@@ -0,0 +1,3 @@
+---
+anta.tests.undefined:
+ - MyTest:
diff --git a/tests/data/test_catalog_with_undefined_module_nested.yml b/tests/data/test_catalog_with_undefined_module_nested.yml
new file mode 100644
index 0000000..cf0f393
--- /dev/null
+++ b/tests/data/test_catalog_with_undefined_module_nested.yml
@@ -0,0 +1,4 @@
+---
+anta.tests:
+ undefined:
+ - MyTest:
diff --git a/tests/data/test_catalog_with_undefined_tests.yml b/tests/data/test_catalog_with_undefined_tests.yml
new file mode 100644
index 0000000..8282714
--- /dev/null
+++ b/tests/data/test_catalog_with_undefined_tests.yml
@@ -0,0 +1,3 @@
+---
+anta.tests.software:
+ - FakeTest:
diff --git a/tests/data/test_catalog_wrong_type.yml b/tests/data/test_catalog_wrong_type.yml
new file mode 100644
index 0000000..3f8f043
--- /dev/null
+++ b/tests/data/test_catalog_wrong_type.yml
@@ -0,0 +1 @@
+"Not a string"
diff --git a/tests/data/test_empty_catalog.yml b/tests/data/test_empty_catalog.yml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/data/test_empty_catalog.yml
diff --git a/tests/data/test_inventory.yml b/tests/data/test_inventory.yml
new file mode 100644
index 0000000..d0ca457
--- /dev/null
+++ b/tests/data/test_inventory.yml
@@ -0,0 +1,12 @@
+---
+anta_inventory:
+ hosts:
+ - name: dummy
+ host: dummy.anta.ninja
+ tags: ["leaf"]
+ - name: dummy2
+ host: dummy2.anta.ninja
+ tags: ["leaf"]
+ - name: dummy3
+ host: dummy3.anta.ninja
+ tags: ["spine"]
diff --git a/tests/data/test_snapshot_commands.yml b/tests/data/test_snapshot_commands.yml
new file mode 100644
index 0000000..d2ee8dc
--- /dev/null
+++ b/tests/data/test_snapshot_commands.yml
@@ -0,0 +1,8 @@
+---
+# list of EOS commands to collect in JSON format
+json_format:
+ - show version
+
+# list of EOS commands to collect in text format
+text_format:
+ - show version
diff --git a/tests/data/toto.yml b/tests/data/toto.yml
new file mode 100644
index 0000000..c0f92cb
--- /dev/null
+++ b/tests/data/toto.yml
@@ -0,0 +1,16 @@
+anta_inventory:
+ hosts:
+ - host: 10.73.1.238
+ name: cv_atd1
+ - host: 192.168.0.10
+ name: spine1
+ - host: 192.168.0.11
+ name: spine2
+ - host: 192.168.0.12
+ name: leaf1
+ - host: 192.168.0.13
+ name: leaf2
+ - host: 192.168.0.14
+ name: leaf3
+ - host: 192.168.0.15
+ name: leaf4
diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/lib/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/lib/anta.py b/tests/lib/anta.py
new file mode 100644
index 0000000..b97d91d
--- /dev/null
+++ b/tests/lib/anta.py
@@ -0,0 +1,34 @@
+# 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.
+"""
+generic test funciton used to generate unit tests for each AntaTest
+"""
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from anta.device import AntaDevice
+
+
+def test(device: AntaDevice, data: dict[str, Any]) -> None:
+ """
+ Generic test function for AntaTest subclass.
+ See `tests/units/anta_tests/README.md` for more information on how to use it.
+ """
+ # Instantiate the AntaTest subclass
+ test_instance = data["test"](device, inputs=data["inputs"], eos_data=data["eos_data"])
+ # Run the test() method
+ asyncio.run(test_instance.test())
+ # Assert expected result
+ assert test_instance.result.result == data["expected"]["result"], test_instance.result.messages
+ if "messages" in data["expected"]:
+ # We expect messages in test result
+ assert len(test_instance.result.messages) == len(data["expected"]["messages"])
+ # Test will pass if the expected message is included in the test result message
+ for message, expected in zip(test_instance.result.messages, data["expected"]["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10
+ assert expected in message
+ else:
+ # Test result should not have messages
+ assert test_instance.result.messages == []
diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py
new file mode 100644
index 0000000..68e9e57
--- /dev/null
+++ b/tests/lib/fixture.py
@@ -0,0 +1,242 @@
+# 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.
+"""Fixture for Anta Testing"""
+from __future__ import annotations
+
+import logging
+import shutil
+from pathlib import Path
+from typing import Any, Callable, Iterator
+from unittest.mock import patch
+
+import pytest
+from click.testing import CliRunner, Result
+from pytest import CaptureFixture
+
+from anta import aioeapi
+from anta.cli.console import console
+from anta.device import AntaDevice, AsyncEOSDevice
+from anta.inventory import AntaInventory
+from anta.models import AntaCommand
+from anta.result_manager import ResultManager
+from anta.result_manager.models import TestResult
+from tests.lib.utils import default_anta_env
+
+logger = logging.getLogger(__name__)
+
+DEVICE_HW_MODEL = "pytest"
+DEVICE_NAME = "pytest"
+COMMAND_OUTPUT = "retrieved"
+
+MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = {
+ "show version": {
+ "modelName": "DCS-7280CR3-32P4-F",
+ "version": "4.31.1F",
+ },
+ "enable": {},
+ "clear counters": {},
+ "clear hardware counter drop": {},
+ "undefined": aioeapi.EapiCommandError(
+ passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[]
+ ),
+}
+
+MOCK_CLI_TEXT: dict[str, aioeapi.EapiCommandError | str] = {
+ "show version": "Arista cEOSLab",
+ "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support": "dummy_tech-support_2023-12-01.1115.log.gz\ndummy_tech-support_2023-12-01.1015.log.gz",
+ "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support | head -1": "dummy_tech-support_2023-12-01.1115.log.gz",
+ "show running-config | include aaa authorization exec default": "aaa authorization exec default local",
+}
+
+
+@pytest.fixture
+def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]:
+ """
+ Returns an AntaDevice instance with mocked abstract method
+ """
+
+ def _collect(command: AntaCommand) -> None:
+ command.output = COMMAND_OUTPUT
+
+ kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL}
+
+ if hasattr(request, "param"):
+ # Fixture is parametrized indirectly
+ kwargs.update(request.param)
+ with patch.object(AntaDevice, "__abstractmethods__", set()):
+ with patch("anta.device.AntaDevice._collect", side_effect=_collect):
+ # AntaDevice constructor does not have hw_model argument
+ hw_model = kwargs.pop("hw_model")
+ dev = AntaDevice(**kwargs) # type: ignore[abstract, arg-type] # pylint: disable=abstract-class-instantiated, unexpected-keyword-arg
+ dev.hw_model = hw_model
+ yield dev
+
+
+@pytest.fixture
+def test_inventory() -> AntaInventory:
+ """
+ Return the test_inventory
+ """
+ env = default_anta_env()
+ assert env["ANTA_INVENTORY"] and env["ANTA_USERNAME"] and env["ANTA_PASSWORD"] is not None
+ return AntaInventory.parse(
+ filename=env["ANTA_INVENTORY"],
+ username=env["ANTA_USERNAME"],
+ password=env["ANTA_PASSWORD"],
+ )
+
+
+# tests.unit.test_device.py fixture
+@pytest.fixture
+def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice:
+ """
+ Returns an AsyncEOSDevice instance
+ """
+
+ kwargs = {"name": DEVICE_NAME, "host": "42.42.42.42", "username": "anta", "password": "anta"}
+
+ if hasattr(request, "param"):
+ # Fixture is parametrized indirectly
+ kwargs.update(request.param)
+ dev = AsyncEOSDevice(**kwargs) # type: ignore[arg-type]
+ return dev
+
+
+# tests.units.result_manager fixtures
+@pytest.fixture
+def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]:
+ """
+ Return a anta.result_manager.models.TestResult object
+ """
+
+ # pylint: disable=redefined-outer-name
+
+ def _create(index: int = 0) -> TestResult:
+ """
+ Actual Factory
+ """
+ return TestResult(
+ name=device.name,
+ test=f"VerifyTest{index}",
+ categories=["test"],
+ description=f"Verifies Test {index}",
+ custom_field=None,
+ )
+
+ return _create
+
+
+@pytest.fixture
+def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]:
+ """
+ Return a list[TestResult] with 'size' TestResult instanciated using the test_result_factory fixture
+ """
+
+ # pylint: disable=redefined-outer-name
+
+ def _factory(size: int = 0) -> list[TestResult]:
+ """
+ Factory for list[TestResult] entry of size entries
+ """
+ result: list[TestResult] = []
+ for i in range(size):
+ result.append(test_result_factory(i))
+ return result
+
+ return _factory
+
+
+@pytest.fixture
+def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]:
+ """
+ Return a ResultManager factory that takes as input a number of tests
+ """
+
+ # pylint: disable=redefined-outer-name
+
+ def _factory(number: int = 0) -> ResultManager:
+ """
+ Factory for list[TestResult] entry of size entries
+ """
+ result_manager = ResultManager()
+ result_manager.add_test_results(list_result_factory(number))
+ return result_manager
+
+ return _factory
+
+
+# tests.units.cli fixtures
+@pytest.fixture
+def temp_env(tmp_path: Path) -> dict[str, str | None]:
+ """Fixture that create a temporary ANTA inventory that can be overriden
+ and returns the corresponding environment variables"""
+ env = default_anta_env()
+ anta_inventory = str(env["ANTA_INVENTORY"])
+ temp_inventory = tmp_path / "test_inventory.yml"
+ shutil.copy(anta_inventory, temp_inventory)
+ env["ANTA_INVENTORY"] = str(temp_inventory)
+ return env
+
+
+@pytest.fixture
+def click_runner(capsys: CaptureFixture[str]) -> Iterator[CliRunner]:
+ """
+ Convenience fixture to return a click.CliRunner for cli testing
+ """
+
+ class AntaCliRunner(CliRunner):
+ """Override CliRunner to inject specific variables for ANTA"""
+
+ def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def]
+ # Inject default env if not provided
+ kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env()
+ # Deterministic terminal width
+ kwargs["env"]["COLUMNS"] = "165"
+
+ kwargs["auto_envvar_prefix"] = "ANTA"
+ # Way to fix https://github.com/pallets/click/issues/824
+ with capsys.disabled():
+ result = super().invoke(*args, **kwargs)
+ print("--- CLI Output ---")
+ print(result.output)
+ return result
+
+ def cli(
+ command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", version: int | str | None = "latest", **kwargs: Any
+ ) -> dict[str, Any] | list[dict[str, Any]]:
+ # pylint: disable=unused-argument
+ def get_output(command: str | dict[str, Any]) -> dict[str, Any]:
+ if isinstance(command, dict):
+ command = command["cmd"]
+ mock_cli: dict[str, Any]
+ if ofmt == "json":
+ mock_cli = MOCK_CLI_JSON
+ elif ofmt == "text":
+ mock_cli = MOCK_CLI_TEXT
+ for mock_cmd, output in mock_cli.items():
+ if command == mock_cmd:
+ logger.info(f"Mocking command {mock_cmd}")
+ if isinstance(output, aioeapi.EapiCommandError):
+ raise output
+ return output
+ message = f"Command '{command}' is not mocked"
+ logger.critical(message)
+ raise NotImplementedError(message)
+
+ res: dict[str, Any] | list[dict[str, Any]]
+ if command is not None:
+ logger.debug(f"Mock input {command}")
+ res = get_output(command)
+ if commands is not None:
+ logger.debug(f"Mock input {commands}")
+ res = list(map(get_output, commands))
+ logger.debug(f"Mock output {res}")
+ return res
+
+ # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py
+ with patch("aioeapi.device.Device.check_connection", return_value=True), patch("aioeapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch(
+ "asyncssh.scp"
+ ):
+ console._color_system = None # pylint: disable=protected-access
+ yield AntaCliRunner()
diff --git a/tests/lib/utils.py b/tests/lib/utils.py
new file mode 100644
index 0000000..460e014
--- /dev/null
+++ b/tests/lib/utils.py
@@ -0,0 +1,49 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+tests.lib.utils
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+
+def generate_test_ids_dict(val: dict[str, Any], key: str = "name") -> str:
+ """
+ generate_test_ids Helper to generate test ID for parametrize
+ """
+ return val.get(key, "unamed_test")
+
+
+def generate_test_ids_list(val: list[dict[str, Any]], key: str = "name") -> list[str]:
+ """
+ generate_test_ids Helper to generate test ID for parametrize
+ """
+ return [entry[key] if key in entry.keys() else "unamed_test" for entry in val]
+
+
+def generate_test_ids(data: list[dict[str, Any]]) -> list[str]:
+ """
+ build id for a unit test of an AntaTest subclass
+
+ {
+ "name": "meaniful test name",
+ "test": <AntaTest instance>,
+ ...
+ }
+ """
+ return [f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" for val in data]
+
+
+def default_anta_env() -> dict[str, str | None]:
+ """
+ Return a default_anta_environement which can be passed to a cliRunner.invoke method
+ """
+ return {
+ "ANTA_USERNAME": "anta",
+ "ANTA_PASSWORD": "formica",
+ "ANTA_INVENTORY": str(Path(__file__).parent.parent / "data" / "test_inventory.yml"),
+ "ANTA_CATALOG": str(Path(__file__).parent.parent / "data" / "test_catalog.yml"),
+ }
diff --git a/tests/mock_data/show_ntp_status_text_synchronised.out b/tests/mock_data/show_ntp_status_text_synchronised.out
new file mode 100644
index 0000000..081a8a8
--- /dev/null
+++ b/tests/mock_data/show_ntp_status_text_synchronised.out
@@ -0,0 +1 @@
+[{'output': 'synchronised to NTP server (51.254.83.231) at stratum 3\n time correct to within 82 ms\n polling server every 1024 s\n\n'}]
diff --git a/tests/mock_data/show_uptime_json_1000000.out b/tests/mock_data/show_uptime_json_1000000.out
new file mode 100644
index 0000000..754025a
--- /dev/null
+++ b/tests/mock_data/show_uptime_json_1000000.out
@@ -0,0 +1 @@
+[{'upTime': 1000000.68, 'loadAvg': [0.17, 0.21, 0.18], 'users': 1, 'currentTime': 1643761588.030645}]
diff --git a/tests/mock_data/show_version_json_4.27.1.1F.out b/tests/mock_data/show_version_json_4.27.1.1F.out
new file mode 100644
index 0000000..fc720d4
--- /dev/null
+++ b/tests/mock_data/show_version_json_4.27.1.1F.out
@@ -0,0 +1 @@
+[{'imageFormatVersion': '2.0', 'uptime': 2697.76, 'modelName': 'DCS-7280TRA-48C6-F', 'internalVersion': '4.27.1.1F-25536724.42711F', 'memTotal': 8098984, 'mfgName': 'Arista', 'serialNumber': 'SSJ16376415', 'systemMacAddress': '44:4c:a8:c7:1f:6b', 'bootupTimestamp': 1643715179.0, 'memFree': 6131068, 'version': '4.27.1.1F', 'configMacAddress': '00:00:00:00:00:00', 'isIntlVersion': False, 'internalBuildId': '38c43eab-c660-477a-915b-5a7b28da781d', 'hardwareRevision': '21.02', 'hwMacAddress': '44:4c:a8:c7:1f:6b', 'architecture': 'i686'}]
diff --git a/tests/units/__init__.py b/tests/units/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/anta_tests/README.md b/tests/units/anta_tests/README.md
new file mode 100644
index 0000000..6e4c5f0
--- /dev/null
+++ b/tests/units/anta_tests/README.md
@@ -0,0 +1,7 @@
+<!--
+ ~ Copyright (c) 2023-2024 Arista Networks, Inc.
+ ~ Use of this source code is governed by the Apache License 2.0
+ ~ that can be found in the LICENSE file.
+ -->
+
+A guide explaining how to write the unit test can be found in the [contribution guide](../../../docs/contribution.md#unit-tests)
diff --git a/tests/units/anta_tests/__init__.py b/tests/units/anta_tests/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/anta_tests/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/anta_tests/routing/__init__.py b/tests/units/anta_tests/routing/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/anta_tests/routing/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py
new file mode 100644
index 0000000..799f058
--- /dev/null
+++ b/tests/units/anta_tests/routing/test_bgp.py
@@ -0,0 +1,3385 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.routing.bgp.py
+"""
+# pylint: disable=C0302
+from __future__ import annotations
+
+from typing import Any
+
+# pylint: disable=C0413
+# because of the patch above
+from anta.tests.routing.bgp import ( # noqa: E402
+ VerifyBGPAdvCommunities,
+ VerifyBGPExchangedRoutes,
+ VerifyBGPPeerASNCap,
+ VerifyBGPPeerCount,
+ VerifyBGPPeerMD5Auth,
+ VerifyBGPPeerMPCaps,
+ VerifyBGPPeerRouteRefreshCap,
+ VerifyBGPPeersHealth,
+ VerifyBGPSpecificPeers,
+ VerifyBGPTimers,
+ VerifyEVPNType2Route,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-count",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}]"]},
+ },
+ {
+ "name": "failure-no-peers",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [{"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.3", "asn": "65120", "peers": {}}}}],
+ "inputs": {"address_families": [{"afi": "ipv6", "safi": "unicast", "vrf": "default", "num_peers": 3}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 3, Actual: 0'}}]"]},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [{"vrfs": {}}],
+ "inputs": {"address_families": [{"afi": "ipv6", "safi": "multicast", "vrf": "DEV", "num_peers": 3}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv6', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}]"]},
+ },
+ {
+ "name": "success-vrf-all",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 3}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-vrf-all",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 5}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'all': 'Expected: 5, Actual: 3'}}]"]},
+ },
+ {
+ "name": "success-multiple-afi",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.0.1": {
+ "description": "DC1-SPINE1",
+ "version": 4,
+ "msgReceived": 3894,
+ "msgSent": 3897,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266296.584472,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.0.2": {
+ "description": "DC1-SPINE2",
+ "version": 4,
+ "msgReceived": 3893,
+ "msgSent": 3902,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266297.433896,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 2},
+ {"afi": "evpn", "num_peers": 2},
+ ]
+ },
+ "expected": {
+ "result": "success",
+ },
+ },
+ {
+ "name": "failure-multiple-afi",
+ "test": VerifyBGPPeerCount,
+ "eos_data": [
+ {
+ "vrfs": {
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ {"vrfs": {}},
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.0.1": {
+ "description": "DC1-SPINE1",
+ "version": 4,
+ "msgReceived": 3894,
+ "msgSent": 3897,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266296.584472,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.0.2": {
+ "description": "DC1-SPINE2",
+ "version": 4,
+ "msgReceived": 3893,
+ "msgSent": 3902,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266297.433896,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 3},
+ {"afi": "evpn", "num_peers": 3},
+ {"afi": "ipv6", "safi": "unicast", "vrf": "default", "num_peers": 3},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 3, Actual: 2'}}, "
+ "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, "
+ "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-issues",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Idle",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]"
+ ],
+ },
+ },
+ {
+ "name": "success-vrf-all",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all"}]},
+ "expected": {
+ "result": "success",
+ },
+ },
+ {
+ "name": "failure-issues-vrf-all",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Idle",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 100,
+ "outMsgQueue": 200,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ },
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}, "
+ "'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 100, 'outMsgQueue': 200}}}}]"
+ ],
+ },
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [{"vrfs": {}}],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "DEV"}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'DEV': 'Not Configured'}}]"]},
+ },
+ {
+ "name": "failure-no-peers",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [{"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.3", "asn": "65120", "peers": {}}}}],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "multicast"}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'default': 'No Peers'}}]"]},
+ },
+ {
+ "name": "success-multiple-afi",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.0.1": {
+ "description": "DC1-SPINE1",
+ "version": 4,
+ "msgReceived": 3894,
+ "msgSent": 3897,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266296.584472,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.0.2": {
+ "description": "DC1-SPINE2",
+ "version": 4,
+ "msgReceived": 3893,
+ "msgSent": 3902,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266297.433896,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD"},
+ {"afi": "evpn"},
+ ]
+ },
+ "expected": {
+ "result": "success",
+ },
+ },
+ {
+ "name": "failure-multiple-afi",
+ "test": VerifyBGPPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 10,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ {"vrfs": {}},
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.0.1": {
+ "description": "DC1-SPINE1",
+ "version": 4,
+ "msgReceived": 3894,
+ "msgSent": 3897,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266296.584472,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.0.2": {
+ "description": "DC1-SPINE2",
+ "version": 4,
+ "msgReceived": 3893,
+ "msgSent": 3902,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266297.433896,
+ "underMaintenance": False,
+ "peerState": "Idle",
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD"},
+ {"afi": "evpn"},
+ {"afi": "ipv6", "safi": "unicast", "vrf": "default"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': "
+ "{'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 10, 'outMsgQueue': 0}}}}, "
+ "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, "
+ "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "peers": ["10.1.255.0", "10.1.255.2"]}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-issues",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.255.0": {
+ "description": "DC1-SPINE1_Ethernet1",
+ "version": 4,
+ "msgReceived": 0,
+ "msgSent": 0,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266295.098931,
+ "underMaintenance": False,
+ "peerState": "Idle",
+ },
+ "10.1.255.2": {
+ "description": "DC1-SPINE2_Ethernet1",
+ "version": 4,
+ "msgReceived": 3759,
+ "msgSent": 3757,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 14,
+ "prefixReceived": 14,
+ "upDownTime": 1694266296.367261,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ }
+ ],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "peers": ["10.1.255.0", "10.1.255.2"]}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]"
+ ],
+ },
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [{"vrfs": {}}],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "peers": ["10.1.255.0"]}]},
+ "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'DEV': 'Not Configured'}}]"]},
+ },
+ {
+ "name": "failure-no-peers",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [{"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.3", "asn": "65120", "peers": {}}}}],
+ "inputs": {"address_families": [{"afi": "ipv4", "safi": "multicast", "peers": ["10.1.255.0"]}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'default': {'10.1.255.0': {'peerNotFound': True}}}}]"],
+ },
+ },
+ {
+ "name": "success-multiple-afi",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.0.1": {
+ "description": "DC1-SPINE1",
+ "version": 4,
+ "msgReceived": 3894,
+ "msgSent": 3897,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266296.584472,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.0.2": {
+ "description": "DC1-SPINE2",
+ "version": 4,
+ "msgReceived": 3893,
+ "msgSent": 3902,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266297.433896,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "peers": ["10.1.254.1", "192.168.1.11"]},
+ {"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-multiple-afi",
+ "test": VerifyBGPSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "PROD": {
+ "vrf": "PROD",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.254.1": {
+ "description": "DC1-LEAF1B",
+ "version": 4,
+ "msgReceived": 3777,
+ "msgSent": 3764,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65120",
+ "prefixAccepted": 2,
+ "prefixReceived": 2,
+ "upDownTime": 1694266296.659891,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "192.168.1.11": {
+ "description": "K8S-CLUSTER1",
+ "version": 4,
+ "msgReceived": 6417,
+ "msgSent": 7546,
+ "inMsgQueue": 10,
+ "outMsgQueue": 0,
+ "asn": "65000",
+ "prefixAccepted": 1,
+ "prefixReceived": 1,
+ "upDownTime": 1694266329.978035,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ },
+ }
+ }
+ },
+ {"vrfs": {}},
+ {
+ "vrfs": {
+ "default": {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": "65120",
+ "peers": {
+ "10.1.0.1": {
+ "description": "DC1-SPINE1",
+ "version": 4,
+ "msgReceived": 3894,
+ "msgSent": 3897,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266296.584472,
+ "underMaintenance": False,
+ "peerState": "Established",
+ },
+ "10.1.0.2": {
+ "description": "DC1-SPINE2",
+ "version": 4,
+ "msgReceived": 3893,
+ "msgSent": 3902,
+ "inMsgQueue": 0,
+ "outMsgQueue": 0,
+ "asn": "65100",
+ "prefixAccepted": 0,
+ "prefixReceived": 0,
+ "upDownTime": 1694266297.433896,
+ "underMaintenance": False,
+ "peerState": "Idle",
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "address_families": [
+ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "peers": ["10.1.254.1", "192.168.1.11"]},
+ {"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]},
+ {"afi": "ipv6", "safi": "unicast", "vrf": "default", "peers": ["10.1.0.1", "10.1.0.2"]},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': "
+ "{'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 10, 'outMsgQueue': 0}}}}, "
+ "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, "
+ "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "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": 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": True,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "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"],
+ "received_routes": ["192.0.254.3/32", "192.0.255.4/32"],
+ },
+ {
+ "peer_address": "172.30.11.5",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.3/32", "192.0.254.5/32"],
+ "received_routes": ["192.0.254.3/32", "192.0.255.4/32"],
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-routes",
+ "test": VerifyBGPExchangedRoutes,
+ "eos_data": [
+ {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}},
+ {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}},
+ {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}},
+ {"vrfs": {"default": {"vrf": "default", "routerId": "192.0.255.1", "asn": "65001", "bgpRouteEntries": {}}}},
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.3/32"],
+ "received_routes": ["192.0.255.3/32"],
+ },
+ {
+ "peer_address": "172.30.11.12",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.31/32"],
+ "received_routes": ["192.0.255.31/32"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not found or routes are not exchanged properly:\n"
+ "{'bgp_peers': {'172.30.11.11': {'default': 'Not configured'}, '172.30.11.12': {'default': 'Not configured'}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPExchangedRoutes,
+ "eos_data": [
+ {"vrfs": {}},
+ {
+ "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": {}},
+ {
+ "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,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "MGMT",
+ "advertised_routes": ["192.0.254.3/32"],
+ "received_routes": ["192.0.255.3/32"],
+ },
+ {
+ "peer_address": "172.30.11.5",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.3/32", "192.0.254.5/32"],
+ "received_routes": ["192.0.254.3/32", "192.0.255.4/32"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["Following BGP peers are not found or routes are not exchanged properly:\n{'bgp_peers': {'172.30.11.11': {'MGMT': 'Not configured'}}}"],
+ },
+ },
+ {
+ "name": "failure-missing-routes",
+ "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": 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": True,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": True,
+ "active": True,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.3/32", "192.0.254.51/32"],
+ "received_routes": ["192.0.254.31/32", "192.0.255.4/32"],
+ },
+ {
+ "peer_address": "172.30.11.5",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.31/32", "192.0.254.5/32"],
+ "received_routes": ["192.0.254.3/32", "192.0.255.41/32"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not found or routes are not exchanged properly:\n{'bgp_peers': "
+ "{'172.30.11.1': {'default': {'advertised_routes': {'192.0.254.51/32': 'Not found'}, 'received_routes': {'192.0.254.31/32': 'Not found'}}}, "
+ "'172.30.11.5': {'default': {'advertised_routes': {'192.0.254.31/32': 'Not found'}, 'received_routes': {'192.0.255.41/32': 'Not found'}}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-invalid-or-inactive-routes",
+ "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": False,
+ "active": True,
+ },
+ }
+ ]
+ },
+ "192.0.254.5/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": False,
+ "active": True,
+ },
+ }
+ ]
+ },
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "bgpRouteEntries": {
+ "192.0.254.3/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": False,
+ "active": False,
+ },
+ }
+ ],
+ },
+ "192.0.255.4/32": {
+ "bgpRoutePaths": [
+ {
+ "routeType": {
+ "valid": False,
+ "active": False,
+ },
+ }
+ ],
+ },
+ },
+ }
+ }
+ },
+ {
+ "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"],
+ "received_routes": ["192.0.254.31/32", "192.0.255.4/32"],
+ },
+ {
+ "peer_address": "172.30.11.5",
+ "vrf": "default",
+ "advertised_routes": ["192.0.254.31/32", "192.0.254.5/32"],
+ "received_routes": ["192.0.254.3/32", "192.0.255.41/32"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not found or routes are not exchanged properly:\n{'bgp_peers': "
+ "{'172.30.11.1': {'default': {'advertised_routes': {'192.0.254.3/32': {'valid': True, 'active': False}, '192.0.254.51/32': 'Not found'}, "
+ "'received_routes': {'192.0.254.31/32': 'Not found', '192.0.255.4/32': {'valid': False, 'active': False}}}}, "
+ "'172.30.11.5': {'default': {'advertised_routes': {'192.0.254.31/32': 'Not found', '192.0.254.5/32': {'valid': True, 'active': False}}, "
+ "'received_routes': {'192.0.254.3/32': {'valid': False, 'active': True}, '192.0.255.41/32': 'Not found'}}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeerMPCaps,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {
+ "ipv4Unicast": {"advertised": True, "received": True, "enabled": True},
+ "ipv4MplsLabels": {"advertised": True, "received": True, "enabled": True},
+ }
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "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": "default",
+ "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"],
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "MGMT",
+ "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": [
+ "Following BGP peer multiprotocol capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPPeerMPCaps,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "default",
+ "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"],
+ },
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "MGMT",
+ "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer multiprotocol capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}, '172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-missing-capabilities",
+ "test": VerifyBGPPeerMPCaps,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default", "capabilities": ["ipv4 Unicast", "L2VpnEVPN"]}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer multiprotocol capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': {'default': {'l2VpnEvpn': 'not found'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-capabilities",
+ "test": VerifyBGPPeerMPCaps,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {
+ "ipv4Unicast": {"advertised": False, "received": False, "enabled": False},
+ "ipv4MplsVpn": {"advertised": False, "received": True, "enabled": False},
+ },
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {
+ "l2VpnEvpn": {"advertised": True, "received": False, "enabled": False},
+ "ipv4MplsVpn": {"advertised": False, "received": False, "enabled": True},
+ },
+ },
+ },
+ {
+ "peerAddress": "172.30.11.11",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {
+ "ipv4Unicast": {"advertised": False, "received": False, "enabled": False},
+ "ipv4MplsVpn": {"advertised": False, "received": False, "enabled": False},
+ },
+ },
+ },
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {"peer_address": "172.30.11.1", "vrf": "default", "capabilities": ["ipv4 unicast", "ipv4 mpls vpn", "L2 vpn EVPN"]},
+ {"peer_address": "172.30.11.10", "vrf": "MGMT", "capabilities": ["ipv4unicast", "ipv4 mplsvpn", "L2vpnEVPN"]},
+ {"peer_address": "172.30.11.11", "vrf": "MGMT", "capabilities": ["Ipv4 Unicast", "ipv4 MPLSVPN", "L2 vpnEVPN"]},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer multiprotocol capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'ipv4Unicast': {'advertised': False, 'received': False, 'enabled': False}, "
+ "'ipv4MplsVpn': {'advertised': False, 'received': True, 'enabled': False}, 'l2VpnEvpn': 'not found'}}, "
+ "'172.30.11.10': {'MGMT': {'ipv4Unicast': 'not found', 'ipv4MplsVpn': {'advertised': False, 'received': False, 'enabled': True}, "
+ "'l2VpnEvpn': {'advertised': True, 'received': False, 'enabled': False}}}, "
+ "'172.30.11.11': {'MGMT': {'ipv4Unicast': {'advertised': False, 'received': False, 'enabled': False}, "
+ "'ipv4MplsVpn': {'advertised': False, 'received': False, 'enabled': False}, 'l2VpnEvpn': 'not found'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeerASNCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "neighborCapabilities": {
+ "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "MGMT",
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-vrf",
+ "test": VerifyBGPPeerASNCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ }
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "neighborCapabilities": {
+ "fourOctetAsnCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ },
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "MGMT",
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "default",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer four octet asn capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}, '172.30.11.10': {'default': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPPeerASNCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ },
+ ]
+ }
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "default",
+ }
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer four octet asn capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-missing-capabilities",
+ "test": VerifyBGPPeerASNCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4MplsLabels": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.10", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer four octet asn capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'fourOctetAsnCap': 'not found'}}, '172.30.11.10': {'MGMT': {'fourOctetAsnCap': 'not found'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-capabilities",
+ "test": VerifyBGPPeerASNCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "fourOctetAsnCap": {"advertised": False, "received": False, "enabled": False},
+ },
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "neighborCapabilities": {
+ "fourOctetAsnCap": {"advertised": True, "received": False, "enabled": True},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.10", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer four octet asn capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'fourOctetAsnCap': {'advertised': False, 'received': False, 'enabled': False}}}, "
+ "'172.30.11.10': {'MGMT': {'fourOctetAsnCap': {'advertised': True, 'received': False, 'enabled': True}}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeerRouteRefreshCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "routeRefreshCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.11",
+ "neighborCapabilities": {
+ "routeRefreshCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "CS",
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-vrf",
+ "test": VerifyBGPPeerRouteRefreshCap,
+ "eos_data": [{"vrfs": {}}],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "MGMT",
+ }
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer route refresh capabilities are not found or not ok:\n{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPPeerRouteRefreshCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ip4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.12",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ip4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.12",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "CS",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer route refresh capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.12': {'default': {'status': 'Not configured'}}, '172.30.11.1': {'CS': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-missing-capabilities",
+ "test": VerifyBGPPeerRouteRefreshCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.11",
+ "neighborCapabilities": {
+ "multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.11", "vrf": "CS"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer route refresh capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'routeRefreshCap': 'not found'}}, '172.30.11.11': {'CS': {'routeRefreshCap': 'not found'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-capabilities",
+ "test": VerifyBGPPeerRouteRefreshCap,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "neighborCapabilities": {
+ "routeRefreshCap": {"advertised": False, "received": False, "enabled": False},
+ },
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.11",
+ "neighborCapabilities": {
+ "routeRefreshCap": {"advertised": True, "received": True, "enabled": True},
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {"bgp_peers": [{"peer_address": "172.30.11.1", "vrf": "default"}, {"peer_address": "172.30.11.11", "vrf": "CS"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peer route refresh capabilities are not found or not ok:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'routeRefreshCap': {'advertised': False, 'received': False, 'enabled': False}}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPPeerMD5Auth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "state": "Established",
+ "md5AuthEnabled": True,
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "state": "Established",
+ "md5AuthEnabled": True,
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "CS",
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-vrf",
+ "test": VerifyBGPPeerMD5Auth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "state": "Established",
+ "md5AuthEnabled": True,
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "MGMT",
+ }
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n"
+ "{'bgp_peers': {'172.30.11.1': {'MGMT': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPPeerMD5Auth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "state": "Established",
+ "md5AuthEnabled": True,
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.11",
+ "state": "Established",
+ "md5AuthEnabled": True,
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "default",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n"
+ "{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}, '172.30.11.11': {'default': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-not-established-peer",
+ "test": VerifyBGPPeerMD5Auth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "state": "Idle",
+ "md5AuthEnabled": True,
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "state": "Idle",
+ "md5AuthEnabled": False,
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "MGMT",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'state': 'Idle', 'md5_auth_enabled': True}}, "
+ "'172.30.11.10': {'MGMT': {'state': 'Idle', 'md5_auth_enabled': False}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-not-md5-peer",
+ "test": VerifyBGPPeerMD5Auth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "state": "Established",
+ },
+ {"peerAddress": "172.30.11.10", "state": "Established", "md5AuthEnabled": False},
+ ]
+ },
+ "MGMT": {"peerList": [{"peerAddress": "172.30.11.11", "state": "Established", "md5AuthEnabled": False}]},
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "MGMT",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured, not established or MD5 authentication is not enabled:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'state': 'Established', 'md5_auth_enabled': None}}, "
+ "'172.30.11.11': {'MGMT': {'state': 'Established', 'md5_auth_enabled': False}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ }
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-multiple-endpoints",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}, {"address": "aac1.ab5d.b41e", "vni": 10010}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-multiple-routes-ip",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-multiple-routes-mac",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-multiple-routes-multiple-paths-ip",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ "ecmpHead": True,
+ },
+ },
+ {
+ "routeType": {
+ "active": False,
+ "valid": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ "ecmpHead": False,
+ },
+ },
+ ]
+ },
+ "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-multiple-routes-multiple-paths-mac",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ "ecmpHead": True,
+ },
+ },
+ {
+ "routeType": {
+ "active": False,
+ "valid": True,
+ "ecmp": True,
+ "ecmpContributor": True,
+ "ecmpHead": False,
+ },
+ },
+ ]
+ },
+ "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-routes",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [{"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}}],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following VXLAN endpoint do not have any EVPN Type-2 route: [('192.168.20.102', 10020)]"],
+ },
+ },
+ {
+ "name": "failure-path-not-active",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following EVPN Type-2 routes do not have at least one valid and active path: ['RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102']"
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-routes-not-active",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": False,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following EVPN Type-2 routes do not have at least one valid and active path: "
+ "['RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102', "
+ "'RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102']"
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-routes-multiple-paths-not-active",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": True,
+ "valid": True,
+ },
+ },
+ {
+ "routeType": {
+ "active": False,
+ "valid": True,
+ },
+ },
+ ]
+ },
+ "RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": False,
+ },
+ },
+ {
+ "routeType": {
+ "active": False,
+ "valid": False,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following EVPN Type-2 routes do not have at least one valid and active path: ['RD: 10.1.0.6:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102']"
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-endpoints",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": False,
+ },
+ },
+ ]
+ },
+ },
+ },
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": False,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "192.168.20.102", "vni": 10020}, {"address": "aac1.ab5d.b41e", "vni": 10010}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following EVPN Type-2 routes do not have at least one valid and active path: "
+ "['RD: 10.1.0.5:500 mac-ip 10020 aac1.ab4e.bec2 192.168.20.102', "
+ "'RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e']"
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-endpoints-one-no-routes",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}},
+ {
+ "vrf": "default",
+ "routerId": "10.1.0.3",
+ "asn": 65120,
+ "evpnRoutes": {
+ "RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e 192.168.10.101": {
+ "evpnRoutePaths": [
+ {
+ "routeType": {
+ "active": False,
+ "valid": False,
+ },
+ },
+ ]
+ },
+ },
+ },
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}, {"address": "192.168.10.101", "vni": 10010}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following VXLAN endpoint do not have any EVPN Type-2 route: [('aa:c1:ab:4e:be:c2', 10020)]",
+ "The following EVPN Type-2 routes do not have at least one valid and active path: "
+ "['RD: 10.1.0.5:500 mac-ip 10010 aac1.ab5d.b41e 192.168.10.101']",
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-endpoints-no-routes",
+ "test": VerifyEVPNType2Route,
+ "eos_data": [
+ {"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}},
+ {"vrf": "default", "routerId": "10.1.0.3", "asn": 65120, "evpnRoutes": {}},
+ ],
+ "inputs": {"vxlan_endpoints": [{"address": "aac1.ab4e.bec2", "vni": 10020}, {"address": "192.168.10.101", "vni": 10010}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following VXLAN endpoint do not have any EVPN Type-2 route: [('aa:c1:ab:4e:be:c2', 10020), ('192.168.10.101', 10010)]"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPAdvCommunities,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "advertisedCommunities": {"standard": True, "extended": True, "large": True},
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "advertisedCommunities": {"standard": True, "extended": True, "large": True},
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "MGMT",
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-vrf",
+ "test": VerifyBGPAdvCommunities,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "advertisedCommunities": {"standard": True, "extended": True, "large": True},
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.17",
+ "vrf": "MGMT",
+ }
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n"
+ "{'bgp_peers': {'172.30.11.17': {'MGMT': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPAdvCommunities,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "advertisedCommunities": {"standard": True, "extended": True, "large": True},
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "advertisedCommunities": {"standard": True, "extended": True, "large": True},
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.12",
+ "vrf": "MGMT",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n"
+ "{'bgp_peers': {'172.30.11.10': {'default': {'status': 'Not configured'}}, '172.30.11.12': {'MGMT': {'status': 'Not configured'}}}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-not-correct-communities",
+ "test": VerifyBGPAdvCommunities,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "advertisedCommunities": {"standard": False, "extended": False, "large": False},
+ }
+ ]
+ },
+ "CS": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.10",
+ "advertisedCommunities": {"standard": True, "extended": True, "large": False},
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ },
+ {
+ "peer_address": "172.30.11.10",
+ "vrf": "CS",
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured or advertised communities are not standard, extended, and large:\n"
+ "{'bgp_peers': {'172.30.11.1': {'default': {'advertised_communities': {'standard': False, 'extended': False, 'large': False}}}, "
+ "'172.30.11.10': {'CS': {'advertised_communities': {'standard': True, 'extended': True, 'large': False}}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBGPTimers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "holdTime": 180,
+ "keepaliveTime": 60,
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.11",
+ "holdTime": 180,
+ "keepaliveTime": 60,
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ "hold_time": 180,
+ "keep_alive_time": 60,
+ },
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "MGMT",
+ "hold_time": 180,
+ "keep_alive_time": 60,
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBGPTimers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "holdTime": 180,
+ "keepaliveTime": 60,
+ }
+ ]
+ },
+ "MGMT": {"peerList": []},
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "MGMT",
+ "hold_time": 180,
+ "keep_alive_time": 60,
+ },
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "MGMT",
+ "hold_time": 180,
+ "keep_alive_time": 60,
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured or hold and keep-alive timers are not correct:\n"
+ "{'172.30.11.1': {'MGMT': 'Not configured'}, '172.30.11.11': {'MGMT': 'Not configured'}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-not-correct-timers",
+ "test": VerifyBGPTimers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.1",
+ "holdTime": 160,
+ "keepaliveTime": 60,
+ }
+ ]
+ },
+ "MGMT": {
+ "peerList": [
+ {
+ "peerAddress": "172.30.11.11",
+ "holdTime": 120,
+ "keepaliveTime": 40,
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bgp_peers": [
+ {
+ "peer_address": "172.30.11.1",
+ "vrf": "default",
+ "hold_time": 180,
+ "keep_alive_time": 60,
+ },
+ {
+ "peer_address": "172.30.11.11",
+ "vrf": "MGMT",
+ "hold_time": 180,
+ "keep_alive_time": 60,
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BGP peers are not configured or hold and keep-alive timers are not correct:\n"
+ "{'172.30.11.1': {'default': {'hold_time': 160, 'keep_alive_time': 60}}, "
+ "'172.30.11.11': {'MGMT': {'hold_time': 120, 'keep_alive_time': 40}}}"
+ ],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py
new file mode 100644
index 0000000..90e70f8
--- /dev/null
+++ b/tests/units/anta_tests/routing/test_generic.py
@@ -0,0 +1,230 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.routing.generic.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.routing.generic import VerifyRoutingProtocolModel, VerifyRoutingTableEntry, VerifyRoutingTableSize
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyRoutingProtocolModel,
+ "eos_data": [{"vrfs": {"default": {}}, "protoModelStatus": {"configuredProtoModel": "multi-agent", "operatingProtoModel": "multi-agent"}}],
+ "inputs": {"model": "multi-agent"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-configured-model",
+ "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"]},
+ },
+ {
+ "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"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyRoutingTableSize,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ # Output truncated
+ "maskLen": {"8": 2},
+ "totalRoutes": 123,
+ }
+ },
+ }
+ ],
+ "inputs": {"minimum": 42, "maximum": 666},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyRoutingTableSize,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ # Output truncated
+ "maskLen": {"8": 2},
+ "totalRoutes": 1000,
+ }
+ },
+ }
+ ],
+ "inputs": {"minimum": 42, "maximum": 666},
+ "expected": {"result": "failure", "messages": ["routing-table has 1000 routes and not between min (42) and maximum (666)"]},
+ },
+ {
+ "name": "error-max-smaller-than-min",
+ "test": VerifyRoutingTableSize,
+ "eos_data": [{}],
+ "inputs": {"minimum": 666, "maximum": 42},
+ "expected": {
+ "result": "error",
+ "messages": ["Minimum 666 is greater than maximum 42"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyRoutingTableEntry,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {
+ "10.1.0.1/32": {
+ "hardwareProgrammed": True,
+ "routeType": "eBGP",
+ "routeLeaked": False,
+ "kernelProgrammed": True,
+ "routeAction": "forward",
+ "directlyConnected": False,
+ "preference": 20,
+ "metric": 0,
+ "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}],
+ }
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {
+ "10.1.0.2/32": {
+ "hardwareProgrammed": True,
+ "routeType": "eBGP",
+ "routeLeaked": False,
+ "kernelProgrammed": True,
+ "routeAction": "forward",
+ "directlyConnected": False,
+ "preference": 20,
+ "metric": 0,
+ "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}],
+ }
+ },
+ }
+ }
+ },
+ ],
+ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-missing-route",
+ "test": VerifyRoutingTableEntry,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {},
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {
+ "10.1.0.2/32": {
+ "hardwareProgrammed": True,
+ "routeType": "eBGP",
+ "routeLeaked": False,
+ "kernelProgrammed": True,
+ "routeAction": "forward",
+ "directlyConnected": False,
+ "preference": 20,
+ "metric": 0,
+ "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}],
+ }
+ },
+ }
+ }
+ },
+ ],
+ "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']"]},
+ },
+ {
+ "name": "failure-wrong-route",
+ "test": VerifyRoutingTableEntry,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {
+ "10.1.0.1/32": {
+ "hardwareProgrammed": True,
+ "routeType": "eBGP",
+ "routeLeaked": False,
+ "kernelProgrammed": True,
+ "routeAction": "forward",
+ "directlyConnected": False,
+ "preference": 20,
+ "metric": 0,
+ "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}],
+ }
+ },
+ }
+ }
+ },
+ {
+ "vrfs": {
+ "default": {
+ "routingDisabled": False,
+ "allRoutesProgrammedHardware": True,
+ "allRoutesProgrammedKernel": True,
+ "defaultRouteState": "notSet",
+ "routes": {
+ "10.1.0.55/32": {
+ "hardwareProgrammed": True,
+ "routeType": "eBGP",
+ "routeLeaked": False,
+ "kernelProgrammed": True,
+ "routeAction": "forward",
+ "directlyConnected": False,
+ "preference": 20,
+ "metric": 0,
+ "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}],
+ }
+ },
+ }
+ }
+ },
+ ],
+ "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']"]},
+ },
+]
diff --git a/tests/units/anta_tests/routing/test_ospf.py b/tests/units/anta_tests/routing/test_ospf.py
new file mode 100644
index 0000000..fbabee9
--- /dev/null
+++ b/tests/units/anta_tests/routing/test_ospf.py
@@ -0,0 +1,298 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.routing.ospf.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.routing.ospf import VerifyOSPFNeighborCount, VerifyOSPFNeighborState
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyOSPFNeighborState,
+ "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",
+ },
+ {
+ "routerId": "9.9.9.9",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "full",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ },
+ ]
+ }
+ }
+ },
+ "BLAH": {
+ "instList": {
+ "777": {
+ "ospfNeighborEntries": [
+ {
+ "routerId": "8.8.8.8",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "full",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ }
+ ]
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyOSPFNeighborState,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "instList": {
+ "666": {
+ "ospfNeighborEntries": [
+ {
+ "routerId": "7.7.7.7",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "2-way",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ },
+ {
+ "routerId": "9.9.9.9",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "full",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ },
+ ]
+ }
+ }
+ },
+ "BLAH": {
+ "instList": {
+ "777": {
+ "ospfNeighborEntries": [
+ {
+ "routerId": "8.8.8.8",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "down",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ }
+ ]
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": None,
+ "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'}]."
+ ],
+ },
+ },
+ {
+ "name": "skipped",
+ "test": VerifyOSPFNeighborState,
+ "eos_data": [
+ {
+ "vrfs": {},
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]},
+ },
+ {
+ "name": "success",
+ "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",
+ },
+ {
+ "routerId": "9.9.9.9",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "full",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ },
+ ]
+ }
+ }
+ },
+ "BLAH": {
+ "instList": {
+ "777": {
+ "ospfNeighborEntries": [
+ {
+ "routerId": "8.8.8.8",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "full",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ }
+ ]
+ }
+ }
+ },
+ }
+ }
+ ],
+ "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,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "instList": {
+ "666": {
+ "ospfNeighborEntries": [
+ {
+ "routerId": "7.7.7.7",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "2-way",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ },
+ {
+ "routerId": "9.9.9.9",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "full",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ },
+ ]
+ }
+ }
+ },
+ "BLAH": {
+ "instList": {
+ "777": {
+ "ospfNeighborEntries": [
+ {
+ "routerId": "8.8.8.8",
+ "priority": 1,
+ "drState": "DR",
+ "interfaceName": "Ethernet1",
+ "adjacencyState": "down",
+ "inactivity": 1683298014.844345,
+ "interfaceAddress": "10.3.0.1",
+ }
+ ]
+ }
+ }
+ },
+ }
+ }
+ ],
+ "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'}]."
+ ],
+ },
+ },
+ {
+ "name": "skipped",
+ "test": VerifyOSPFNeighborCount,
+ "eos_data": [
+ {
+ "vrfs": {},
+ }
+ ],
+ "inputs": {"number": 3},
+ "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_aaa.py b/tests/units/anta_tests/test_aaa.py
new file mode 100644
index 0000000..2992290
--- /dev/null
+++ b/tests/units/anta_tests/test_aaa.py
@@ -0,0 +1,516 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.aaa.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.aaa import (
+ VerifyAcctConsoleMethods,
+ VerifyAcctDefaultMethods,
+ VerifyAuthenMethods,
+ VerifyAuthzMethods,
+ VerifyTacacsServerGroups,
+ VerifyTacacsServers,
+ VerifyTacacsSourceIntf,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyTacacsSourceIntf,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management0"},
+ }
+ ],
+ "inputs": {"intf": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyTacacsSourceIntf,
+ "eos_data": [
+ {
+ "tacacsServers": [],
+ "groups": {},
+ "srcIntf": {},
+ }
+ ],
+ "inputs": {"intf": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]},
+ },
+ {
+ "name": "failure-wrong-intf",
+ "test": VerifyTacacsSourceIntf,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management1"},
+ }
+ ],
+ "inputs": {"intf": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["Wrong source-interface configured in VRF MGMT"]},
+ },
+ {
+ "name": "failure-wrong-vrf",
+ "test": VerifyTacacsSourceIntf,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"PROD": "Management0"},
+ }
+ ],
+ "inputs": {"intf": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTacacsServers,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management0"},
+ }
+ ],
+ "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-servers",
+ "test": VerifyTacacsServers,
+ "eos_data": [
+ {
+ "tacacsServers": [],
+ "groups": {},
+ "srcIntf": {},
+ }
+ ],
+ "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["No TACACS servers are configured"]},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyTacacsServers,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management0"},
+ }
+ ],
+ "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"]},
+ },
+ {
+ "name": "failure-wrong-vrf",
+ "test": VerifyTacacsServers,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "PROD"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management0"},
+ }
+ ],
+ "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.91'] are not configured in VRF MGMT"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTacacsServerGroups,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management0"},
+ }
+ ],
+ "inputs": {"groups": ["GROUP1"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-server-groups",
+ "test": VerifyTacacsServerGroups,
+ "eos_data": [
+ {
+ "tacacsServers": [],
+ "groups": {},
+ "srcIntf": {},
+ }
+ ],
+ "inputs": {"groups": ["GROUP1"]},
+ "expected": {"result": "failure", "messages": ["No TACACS server group(s) are configured"]},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyTacacsServerGroups,
+ "eos_data": [
+ {
+ "tacacsServers": [
+ {
+ "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"},
+ }
+ ],
+ "groups": {"GROUP2": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}},
+ "srcIntf": {"MGMT": "Management0"},
+ }
+ ],
+ "inputs": {"groups": ["GROUP1"]},
+ "expected": {"result": "failure", "messages": ["TACACS server group(s) ['GROUP1'] are not configured"]},
+ },
+ {
+ "name": "success-login-enable",
+ "test": VerifyAuthenMethods,
+ "eos_data": [
+ {
+ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group tacacs+", "local"]}},
+ "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}},
+ "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-dot1x",
+ "test": VerifyAuthenMethods,
+ "eos_data": [
+ {
+ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group tacacs+", "local"]}},
+ "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}},
+ "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}},
+ }
+ ],
+ "inputs": {"methods": ["radius"], "types": ["dot1x"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-login-console",
+ "test": VerifyAuthenMethods,
+ "eos_data": [
+ {
+ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}},
+ "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}},
+ "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]},
+ "expected": {"result": "failure", "messages": ["AAA authentication methods are not configured for login console"]},
+ },
+ {
+ "name": "failure-login-console",
+ "test": VerifyAuthenMethods,
+ "eos_data": [
+ {
+ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group radius", "local"]}},
+ "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}},
+ "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]},
+ "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for login console"]},
+ },
+ {
+ "name": "failure-login-default",
+ "test": VerifyAuthenMethods,
+ "eos_data": [
+ {
+ "loginAuthenMethods": {"default": {"methods": ["group radius", "local"]}, "login": {"methods": ["group tacacs+", "local"]}},
+ "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}},
+ "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]},
+ "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for ['login']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAuthzMethods,
+ "eos_data": [
+ {
+ "commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}},
+ "execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-commands",
+ "test": VerifyAuthzMethods,
+ "eos_data": [
+ {
+ "commandsAuthzMethods": {"privilege0-15": {"methods": ["group radius", "local"]}},
+ "execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
+ "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['commands']"]},
+ },
+ {
+ "name": "failure-exec",
+ "test": VerifyAuthzMethods,
+ "eos_data": [
+ {
+ "commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}},
+ "execAuthzMethods": {"exec": {"methods": ["group radius", "local"]}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]},
+ "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['exec']"]},
+ },
+ {
+ "name": "success-commands-exec-system",
+ "test": VerifyAcctDefaultMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {"privilege0-15": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-dot1x",
+ "test": VerifyAcctDefaultMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {"privilege0-15": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "dot1xAcctMethods": {"dot1x": {"defaultAction": "startStop", "defaultMethods": ["group radius", "logging"], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["radius", "logging"], "types": ["dot1x"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyAcctDefaultMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}},
+ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['commands']"]},
+ },
+ {
+ "name": "failure-not-configured-empty",
+ "test": VerifyAcctDefaultMethods,
+ "eos_data": [
+ {
+ "systemAcctMethods": {"system": {"defaultMethods": [], "consoleMethods": []}},
+ "execAcctMethods": {"exec": {"defaultMethods": [], "consoleMethods": []}},
+ "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}},
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['system', 'exec', 'commands']"]},
+ },
+ {
+ "name": "failure-not-matching",
+ "test": VerifyAcctDefaultMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {"privilege0-15": {"defaultAction": "startStop", "defaultMethods": ["group radius", "logging"], "consoleMethods": []}},
+ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}},
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "failure", "messages": ["AAA accounting default methods ['group tacacs+', 'logging'] are not matching for ['commands']"]},
+ },
+ {
+ "name": "success-commands-exec-system",
+ "test": VerifyAcctConsoleMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {
+ "privilege0-15": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "execAcctMethods": {
+ "exec": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "systemAcctMethods": {
+ "system": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-dot1x",
+ "test": VerifyAcctConsoleMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {
+ "privilege0-15": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "execAcctMethods": {
+ "exec": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "systemAcctMethods": {
+ "system": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "dot1xAcctMethods": {
+ "dot1x": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["dot1x"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyAcctConsoleMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {
+ "privilege0-15": {
+ "defaultMethods": [],
+ "consoleMethods": [],
+ }
+ },
+ "execAcctMethods": {
+ "exec": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "systemAcctMethods": {
+ "system": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['commands']"]},
+ },
+ {
+ "name": "failure-not-configured-empty",
+ "test": VerifyAcctConsoleMethods,
+ "eos_data": [
+ {
+ "systemAcctMethods": {"system": {"defaultMethods": [], "consoleMethods": []}},
+ "execAcctMethods": {"exec": {"defaultMethods": [], "consoleMethods": []}},
+ "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}},
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['system', 'exec', 'commands']"]},
+ },
+ {
+ "name": "failure-not-matching",
+ "test": VerifyAcctConsoleMethods,
+ "eos_data": [
+ {
+ "commandsAcctMethods": {
+ "privilege0-15": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group radius", "logging"],
+ }
+ },
+ "execAcctMethods": {
+ "exec": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "systemAcctMethods": {
+ "system": {
+ "defaultMethods": [],
+ "consoleAction": "startStop",
+ "consoleMethods": ["group tacacs+", "logging"],
+ }
+ },
+ "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}},
+ }
+ ],
+ "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]},
+ "expected": {"result": "failure", "messages": ["AAA accounting console methods ['group tacacs+', 'logging'] are not matching for ['commands']"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py
new file mode 100644
index 0000000..67bb0b4
--- /dev/null
+++ b/tests/units/anta_tests/test_bfd.py
@@ -0,0 +1,523 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.bfd.py
+"""
+# pylint: disable=C0302
+from __future__ import annotations
+
+from typing import Any
+
+# pylint: disable=C0413
+# because of the patch above
+from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDSpecificPeers # noqa: E402
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyBFDPeersIntervals,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1200000,
+ "operRxInterval": 1200000,
+ "detectMult": 3,
+ }
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1200000,
+ "operRxInterval": 1200000,
+ "detectMult": 3,
+ }
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bfd_peers": [
+ {"peer_address": "192.0.255.7", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3},
+ {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBFDPeersIntervals,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1200000,
+ "operRxInterval": 1200000,
+ "detectMult": 3,
+ }
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.71": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1200000,
+ "operRxInterval": 1200000,
+ "detectMult": 3,
+ }
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "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},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BFD peers are not configured or timers are not correct:\n"
+ "{'192.0.255.7': {'CS': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-incorrect-timers",
+ "test": VerifyBFDPeersIntervals,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 1300000,
+ "operRxInterval": 1200000,
+ "detectMult": 4,
+ }
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "peerStatsDetail": {
+ "operTxInterval": 120000,
+ "operRxInterval": 120000,
+ "detectMult": 5,
+ }
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {
+ "bfd_peers": [
+ {"peer_address": "192.0.255.7", "vrf": "default", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3},
+ {"peer_address": "192.0.255.70", "vrf": "MGMT", "tx_interval": 1200, "rx_interval": 1200, "multiplier": 3},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BFD peers are not configured or timers are not correct:\n"
+ "{'192.0.255.7': {'default': {'tx_interval': 1300000, 'rx_interval': 1200000, 'multiplier': 4}}, "
+ "'192.0.255.70': {'MGMT': {'tx_interval': 120000, 'rx_interval': 120000, 'multiplier': 5}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBFDSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 108328132,
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 108328132,
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {"bfd_peers": [{"peer_address": "192.0.255.7", "vrf": "default"}, {"peer_address": "192.0.255.70", "vrf": "MGMT"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBFDSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 108328132,
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.71": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 108328132,
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {"bfd_peers": [{"peer_address": "192.0.255.7", "vrf": "CS"}, {"peer_address": "192.0.255.70", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BFD peers are not configured, status is not up or remote disc is zero:\n"
+ "{'192.0.255.7': {'CS': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-session-down",
+ "test": VerifyBFDSpecificPeers,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "Down",
+ "remoteDisc": 108328132,
+ }
+ }
+ }
+ }
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "status": "Down",
+ "remoteDisc": 0,
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {"bfd_peers": [{"peer_address": "192.0.255.7", "vrf": "default"}, {"peer_address": "192.0.255.70", "vrf": "MGMT"}]},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BFD peers are not configured, status is not up or remote disc is zero:\n"
+ "{'192.0.255.7': {'default': {'status': 'Down', 'remote_disc': 108328132}}, "
+ "'192.0.255.70': {'MGMT': {'status': 'Down', 'remote_disc': 0}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBFDPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 3940685114,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ },
+ "ipv6Neighbors": {},
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.71": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 3940685114,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ },
+ "ipv6Neighbors": {},
+ },
+ }
+ },
+ {
+ "utcTime": 1703667348.111288,
+ },
+ ],
+ "inputs": {"down_threshold": 2},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-peer",
+ "test": VerifyBFDPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "MGMT": {
+ "ipv6Neighbors": {},
+ "ipv4Neighbors": {},
+ },
+ "default": {
+ "ipv6Neighbors": {},
+ "ipv4Neighbors": {},
+ },
+ }
+ },
+ {
+ "utcTime": 1703658481.8778424,
+ },
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": ["No IPv4 BFD peers are configured for any VRF."],
+ },
+ },
+ {
+ "name": "failure-session-down",
+ "test": VerifyBFDPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "down",
+ "remoteDisc": 0,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ "192.0.255.70": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 3940685114,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ },
+ "ipv6Neighbors": {},
+ },
+ "MGMT": {
+ "ipv4Neighbors": {
+ "192.0.255.71": {
+ "peerStats": {
+ "": {
+ "status": "down",
+ "remoteDisc": 0,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ },
+ "ipv6Neighbors": {},
+ },
+ }
+ },
+ {
+ "utcTime": 1703658481.8778424,
+ },
+ ],
+ "inputs": {},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BFD peers are not up:\n192.0.255.7 is down in default VRF with remote disc 0.\n192.0.255.71 is down in MGMT VRF with remote disc 0."
+ ],
+ },
+ },
+ {
+ "name": "failure-session-up-disc",
+ "test": VerifyBFDPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 0,
+ "lastDown": 1703657258.652725,
+ "l3intf": "Ethernet2",
+ }
+ }
+ },
+ "192.0.255.71": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 0,
+ "lastDown": 1703657258.652725,
+ "l3intf": "Ethernet2",
+ }
+ }
+ },
+ },
+ "ipv6Neighbors": {},
+ }
+ }
+ },
+ {
+ "utcTime": 1703658481.8778424,
+ },
+ ],
+ "inputs": {},
+ "expected": {
+ "result": "failure",
+ "messages": ["Following BFD peers were down:\n192.0.255.7 in default VRF has remote disc 0.\n192.0.255.71 in default VRF has remote disc 0."],
+ },
+ },
+ {
+ "name": "failure-last-down",
+ "test": VerifyBFDPeersHealth,
+ "eos_data": [
+ {
+ "vrfs": {
+ "default": {
+ "ipv4Neighbors": {
+ "192.0.255.7": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 3940685114,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ "192.0.255.71": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 3940685114,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ "192.0.255.17": {
+ "peerStats": {
+ "": {
+ "status": "up",
+ "remoteDisc": 3940685114,
+ "lastDown": 1703657258.652725,
+ "l3intf": "",
+ }
+ }
+ },
+ },
+ "ipv6Neighbors": {},
+ }
+ }
+ },
+ {
+ "utcTime": 1703667348.111288,
+ },
+ ],
+ "inputs": {"down_threshold": 4},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Following BFD peers were down:\n192.0.255.7 in default VRF was down 3 hours ago.\n"
+ "192.0.255.71 in default VRF was down 3 hours ago.\n192.0.255.17 in default VRF was down 3 hours ago."
+ ],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_configuration.py b/tests/units/anta_tests/test_configuration.py
new file mode 100644
index 0000000..a2ab673
--- /dev/null
+++ b/tests/units/anta_tests/test_configuration.py
@@ -0,0 +1,35 @@
+# 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.
+"""Data for testing anta.tests.configuration"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.configuration import VerifyRunningConfigDiffs, VerifyZeroTouch
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyZeroTouch,
+ "eos_data": [{"mode": "disabled"}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyZeroTouch,
+ "eos_data": [{"mode": "enabled"}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["ZTP is NOT disabled"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyRunningConfigDiffs,
+ "eos_data": [""],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {"name": "failure", "test": VerifyRunningConfigDiffs, "eos_data": ["blah blah"], "inputs": None, "expected": {"result": "failure", "messages": ["blah blah"]}},
+]
diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py
new file mode 100644
index 0000000..f79ce24
--- /dev/null
+++ b/tests/units/anta_tests/test_connectivity.py
@@ -0,0 +1,369 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.connectivity.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.connectivity import VerifyLLDPNeighbors, VerifyReachability
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success-ip",
+ "test": VerifyReachability,
+ "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "10.0.0.5"}, {"destination": "10.0.0.2", "source": "10.0.0.5"}]},
+ "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
+
+ """
+ ]
+ },
+ {
+ "messages": [
+ """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data.
+ 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms
+ 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms
+
+ --- 10.0.0.2 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
+
+ """
+ ]
+ },
+ ],
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-interface",
+ "test": VerifyReachability,
+ "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0"}, {"destination": "10.0.0.2", "source": "Management0"}]},
+ "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
+
+ """
+ ]
+ },
+ {
+ "messages": [
+ """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data.
+ 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms
+ 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms
+
+ --- 10.0.0.2 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
+
+ """
+ ]
+ },
+ ],
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-repeat",
+ "test": VerifyReachability,
+ "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0", "repeat": 1}]},
+ "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
+
+ --- 10.0.0.1 ping statistics ---
+ 1 packets transmitted, 1 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
+
+ """
+ ]
+ },
+ ],
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-ip",
+ "test": VerifyReachability,
+ "inputs": {"hosts": [{"destination": "10.0.0.11", "source": "10.0.0.5"}, {"destination": "10.0.0.2", "source": "10.0.0.5"}]},
+ "eos_data": [
+ {
+ "messages": [
+ """ping: sendmsg: Network is unreachable
+ ping: sendmsg: Network is unreachable
+ PING 10.0.0.11 (10.0.0.11) from 10.0.0.5 : 72(100) bytes of data.
+
+ --- 10.0.0.11 ping statistics ---
+ 2 packets transmitted, 0 received, 100% packet loss, time 10ms
+
+
+ """
+ ]
+ },
+ {
+ "messages": [
+ """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data.
+ 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms
+ 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms
+
+ --- 10.0.0.2 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
+
+ """
+ ]
+ },
+ ],
+ "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('10.0.0.5', '10.0.0.11')]"]},
+ },
+ {
+ "name": "failure-interface",
+ "test": VerifyReachability,
+ "inputs": {"hosts": [{"destination": "10.0.0.11", "source": "Management0"}, {"destination": "10.0.0.2", "source": "Management0"}]},
+ "eos_data": [
+ {
+ "messages": [
+ """ping: sendmsg: Network is unreachable
+ ping: sendmsg: Network is unreachable
+ PING 10.0.0.11 (10.0.0.11) from 10.0.0.5 : 72(100) bytes of data.
+
+ --- 10.0.0.11 ping statistics ---
+ 2 packets transmitted, 0 received, 100% packet loss, time 10ms
+
+
+ """
+ ]
+ },
+ {
+ "messages": [
+ """PING 10.0.0.2 (10.0.0.2) from 10.0.0.5 : 72(100) bytes of data.
+ 80 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.247 ms
+ 80 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.072 ms
+
+ --- 10.0.0.2 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
+
+ """
+ ]
+ },
+ ],
+ "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.11')]"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLLDPNeighbors,
+ "inputs": {
+ "neighbors": [
+ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"},
+ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
+ ]
+ },
+ "eos_data": [
+ {
+ "lldpNeighbors": {
+ "Ethernet1": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73a0.fc18",
+ "systemName": "DC1-SPINE1",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet1"',
+ "interfaceId_v2": "Ethernet1",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1",
+ },
+ }
+ ]
+ },
+ "Ethernet2": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73f7.d138",
+ "systemName": "DC1-SPINE2",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet1"',
+ "interfaceId_v2": "Ethernet1",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet2",
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-port-not-configured",
+ "test": VerifyLLDPNeighbors,
+ "inputs": {
+ "neighbors": [
+ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"},
+ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
+ ]
+ },
+ "eos_data": [
+ {
+ "lldpNeighbors": {
+ "Ethernet1": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73a0.fc18",
+ "systemName": "DC1-SPINE1",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet1"',
+ "interfaceId_v2": "Ethernet1",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1",
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'port_not_configured': ['Ethernet2']}"]},
+ },
+ {
+ "name": "failure-no-neighbor",
+ "test": VerifyLLDPNeighbors,
+ "inputs": {
+ "neighbors": [
+ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"},
+ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
+ ]
+ },
+ "eos_data": [
+ {
+ "lldpNeighbors": {
+ "Ethernet1": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73a0.fc18",
+ "systemName": "DC1-SPINE1",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet1"',
+ "interfaceId_v2": "Ethernet1",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1",
+ },
+ }
+ ]
+ },
+ "Ethernet2": {"lldpNeighborInfo": []},
+ }
+ }
+ ],
+ "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'no_lldp_neighbor': ['Ethernet2']}"]},
+ },
+ {
+ "name": "failure-wrong-neighbor",
+ "test": VerifyLLDPNeighbors,
+ "inputs": {
+ "neighbors": [
+ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"},
+ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
+ ]
+ },
+ "eos_data": [
+ {
+ "lldpNeighbors": {
+ "Ethernet1": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73a0.fc18",
+ "systemName": "DC1-SPINE1",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet1"',
+ "interfaceId_v2": "Ethernet1",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1",
+ },
+ }
+ ]
+ },
+ "Ethernet2": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73f7.d138",
+ "systemName": "DC1-SPINE2",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet2"',
+ "interfaceId_v2": "Ethernet2",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet2",
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet2']}"]},
+ },
+ {
+ "name": "failure-multiple",
+ "test": VerifyLLDPNeighbors,
+ "inputs": {
+ "neighbors": [
+ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"},
+ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"},
+ {"port": "Ethernet3", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"},
+ ]
+ },
+ "eos_data": [
+ {
+ "lldpNeighbors": {
+ "Ethernet1": {
+ "lldpNeighborInfo": [
+ {
+ "chassisIdType": "macAddress",
+ "chassisId": "001c.73a0.fc18",
+ "systemName": "DC1-SPINE1",
+ "neighborInterfaceInfo": {
+ "interfaceIdType": "interfaceName",
+ "interfaceId": '"Ethernet2"',
+ "interfaceId_v2": "Ethernet2",
+ "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1",
+ },
+ }
+ ]
+ },
+ "Ethernet2": {"lldpNeighborInfo": []},
+ }
+ }
+ ],
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet1'], 'no_lldp_neighbor': ['Ethernet2'], 'port_not_configured': ['Ethernet3']}"
+ ],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py
new file mode 100644
index 0000000..7c17f22
--- /dev/null
+++ b/tests/units/anta_tests/test_field_notices.py
@@ -0,0 +1,291 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Test inputs for anta.tests.field_notices"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.field_notices import VerifyFieldNotice44Resolution, VerifyFieldNotice72Resolution
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyFieldNotice44Resolution,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1109144.35,
+ "modelName": "DCS-7280QRA-C36S",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-4.0",
+ "test": VerifyFieldNotice44Resolution,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1109144.35,
+ "modelName": "DCS-7280QRA-C36S",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-4.0.1-3255441"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (4.0.1)"]},
+ },
+ {
+ "name": "failure-4.1",
+ "test": VerifyFieldNotice44Resolution,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1109144.35,
+ "modelName": "DCS-7280QRA-C36S",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-4.1.0-3255441"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (4.1.0)"]},
+ },
+ {
+ "name": "failure-6.0",
+ "test": VerifyFieldNotice44Resolution,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1109144.35,
+ "modelName": "DCS-7280QRA-C36S",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-6.0.1-3255441"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (6.0.1)"]},
+ },
+ {
+ "name": "failure-6.1",
+ "test": VerifyFieldNotice44Resolution,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1109144.35,
+ "modelName": "DCS-7280QRA-C36S",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-6.1.1-3255441"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (6.1.1)"]},
+ },
+ {
+ "name": "skipped-model",
+ "test": VerifyFieldNotice44Resolution,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1109144.35,
+ "modelName": "vEOS-lab",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["device is not impacted by FN044"]},
+ },
+ {
+ "name": "success-JPE",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3-48YC8",
+ "serialNumber": "JPE2130000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "7"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success", "messages": ["FN72 is mitigated"]},
+ },
+ {
+ "name": "success-JAS",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3-48YC8",
+ "serialNumber": "JAS2040000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "7"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success", "messages": ["FN72 is mitigated"]},
+ },
+ {
+ "name": "success-K-JPE",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3K-48YC8",
+ "serialNumber": "JPE2133000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "7"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success", "messages": ["FN72 is mitigated"]},
+ },
+ {
+ "name": "success-K-JAS",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3K-48YC8",
+ "serialNumber": "JAS2040000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "7"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success", "messages": ["FN72 is mitigated"]},
+ },
+ {
+ "name": "skipped-Serial",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3K-48YC8",
+ "serialNumber": "BAN2040000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "7"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["Device not exposed"]},
+ },
+ {
+ "name": "skipped-Platform",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7150-52-CL",
+ "serialNumber": "JAS0040000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "5"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["Platform is not impacted by FN072"]},
+ },
+ {
+ "name": "skipped-range-JPE",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3-48YC8",
+ "serialNumber": "JPE2131000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "5"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["Device not exposed"]},
+ },
+ {
+ "name": "skipped-range-K-JAS",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3K-48YC8",
+ "serialNumber": "JAS2041000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "5"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["Device not exposed"]},
+ },
+ {
+ "name": "failed-JPE",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3K-48YC8",
+ "serialNumber": "JPE2133000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "5"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device is exposed to FN72"]},
+ },
+ {
+ "name": "failed-JAS",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3-48YC8",
+ "serialNumber": "JAS2040000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm1", "version": "5"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device is exposed to FN72"]},
+ },
+ {
+ "name": "error",
+ "test": VerifyFieldNotice72Resolution,
+ "eos_data": [
+ {
+ "modelName": "DCS-7280SR3-48YC8",
+ "serialNumber": "JAS2040000",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "FixedSystemvrm2", "version": "5"}],
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "error", "messages": ["Error in running test - FixedSystemvrm1 not found"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_greent.py b/tests/units/anta_tests/test_greent.py
new file mode 100644
index 0000000..65789a2
--- /dev/null
+++ b/tests/units/anta_tests/test_greent.py
@@ -0,0 +1,47 @@
+# 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.
+"""Data for testing anta.tests.configuration"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.greent import VerifyGreenT, VerifyGreenTCounters
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyGreenTCounters,
+ "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 1, "sampleSent": 0}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyGreenTCounters,
+ "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 0, "sampleSent": 0}],
+ "inputs": None,
+ "expected": {"result": "failure"},
+ },
+ {
+ "name": "success",
+ "test": VerifyGreenT,
+ "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 1, "sampleSent": 0}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyGreenT,
+ "eos_data": [
+ {
+ "profiles": {
+ "default": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}},
+ "testProfile": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}},
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure"},
+ },
+]
diff --git a/tests/units/anta_tests/test_hardware.py b/tests/units/anta_tests/test_hardware.py
new file mode 100644
index 0000000..5279d89
--- /dev/null
+++ b/tests/units/anta_tests/test_hardware.py
@@ -0,0 +1,918 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Test inputs for anta.tests.hardware"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.hardware import (
+ VerifyAdverseDrops,
+ VerifyEnvironmentCooling,
+ VerifyEnvironmentPower,
+ VerifyEnvironmentSystemCooling,
+ VerifyTemperature,
+ VerifyTransceiversManufacturers,
+ VerifyTransceiversTemperature,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyTransceiversManufacturers,
+ "eos_data": [
+ {
+ "xcvrSlots": {
+ "1": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501340", "hardwareRev": "21"},
+ "2": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501337", "hardwareRev": "21"},
+ }
+ }
+ ],
+ "inputs": {"manufacturers": ["Arista Networks"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyTransceiversManufacturers,
+ "eos_data": [
+ {
+ "xcvrSlots": {
+ "1": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501340", "hardwareRev": "21"},
+ "2": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501337", "hardwareRev": "21"},
+ }
+ }
+ ],
+ "inputs": {"manufacturers": ["Arista"]},
+ "expected": {"result": "failure", "messages": ["Some transceivers are from unapproved manufacturers: {'1': 'Arista Networks', '2': 'Arista Networks'}"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTemperature,
+ "eos_data": [
+ {
+ "powercycleOnOverheat": "False",
+ "ambientThreshold": 45,
+ "cardSlots": [],
+ "shutdownOnOverheat": "True",
+ "systemStatus": "temperatureOk",
+ "recoveryModeOnOverheat": "recoveryModeNA",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyTemperature,
+ "eos_data": [
+ {
+ "powercycleOnOverheat": "False",
+ "ambientThreshold": 45,
+ "cardSlots": [],
+ "shutdownOnOverheat": "True",
+ "systemStatus": "temperatureKO",
+ "recoveryModeOnOverheat": "recoveryModeNA",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device temperature exceeds acceptable limits. Current system status: 'temperatureKO'"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTransceiversTemperature,
+ "eos_data": [
+ {
+ "tempSensors": [
+ {
+ "maxTemperature": 25.03125,
+ "maxTemperatureLastChange": 1682509618.2227979,
+ "hwStatus": "ok",
+ "alertCount": 0,
+ "description": "Xcvr54 temp sensor",
+ "overheatThreshold": 70.0,
+ "criticalThreshold": 70.0,
+ "inAlertState": False,
+ "targetTemperature": 62.0,
+ "relPos": "54",
+ "currentTemperature": 24.171875,
+ "setPointTemperature": 61.8,
+ "pidDriverCount": 0,
+ "isPidDriver": False,
+ "name": "DomTemperatureSensor54",
+ }
+ ],
+ "cardSlots": [],
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-hwStatus",
+ "test": VerifyTransceiversTemperature,
+ "eos_data": [
+ {
+ "tempSensors": [
+ {
+ "maxTemperature": 25.03125,
+ "maxTemperatureLastChange": 1682509618.2227979,
+ "hwStatus": "ko",
+ "alertCount": 0,
+ "description": "Xcvr54 temp sensor",
+ "overheatThreshold": 70.0,
+ "criticalThreshold": 70.0,
+ "inAlertState": False,
+ "targetTemperature": 62.0,
+ "relPos": "54",
+ "currentTemperature": 24.171875,
+ "setPointTemperature": 61.8,
+ "pidDriverCount": 0,
+ "isPidDriver": False,
+ "name": "DomTemperatureSensor54",
+ }
+ ],
+ "cardSlots": [],
+ }
+ ],
+ "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}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-alertCount",
+ "test": VerifyTransceiversTemperature,
+ "eos_data": [
+ {
+ "tempSensors": [
+ {
+ "maxTemperature": 25.03125,
+ "maxTemperatureLastChange": 1682509618.2227979,
+ "hwStatus": "ok",
+ "alertCount": 1,
+ "description": "Xcvr54 temp sensor",
+ "overheatThreshold": 70.0,
+ "criticalThreshold": 70.0,
+ "inAlertState": False,
+ "targetTemperature": 62.0,
+ "relPos": "54",
+ "currentTemperature": 24.171875,
+ "setPointTemperature": 61.8,
+ "pidDriverCount": 0,
+ "isPidDriver": False,
+ "name": "DomTemperatureSensor54",
+ }
+ ],
+ "cardSlots": [],
+ }
+ ],
+ "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}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyEnvironmentSystemCooling,
+ "eos_data": [
+ {
+ "defaultZones": False,
+ "numCoolingZones": [],
+ "coolingMode": "automatic",
+ "ambientTemperature": 24.5,
+ "shutdownOnInsufficientFans": True,
+ "airflowDirection": "frontToBackAirflow",
+ "overrideFanSpeed": 0,
+ "powerSupplySlots": [],
+ "fanTraySlots": [],
+ "minFanSpeed": 0,
+ "currentZones": 1,
+ "configuredZones": 0,
+ "systemStatus": "coolingOk",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyEnvironmentSystemCooling,
+ "eos_data": [
+ {
+ "defaultZones": False,
+ "numCoolingZones": [],
+ "coolingMode": "automatic",
+ "ambientTemperature": 24.5,
+ "shutdownOnInsufficientFans": True,
+ "airflowDirection": "frontToBackAirflow",
+ "overrideFanSpeed": 0,
+ "powerSupplySlots": [],
+ "fanTraySlots": [],
+ "minFanSpeed": 0,
+ "currentZones": 1,
+ "configuredZones": 0,
+ "systemStatus": "coolingKo",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device system cooling is not OK: 'coolingKo'"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyEnvironmentCooling,
+ "eos_data": [
+ {
+ "defaultZones": False,
+ "numCoolingZones": [],
+ "coolingMode": "automatic",
+ "ambientTemperature": 24.5,
+ "shutdownOnInsufficientFans": True,
+ "airflowDirection": "frontToBackAirflow",
+ "overrideFanSpeed": 0,
+ "powerSupplySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498937.0240965,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499033.0403435,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498935.9121106,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499092.4665174,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply2",
+ },
+ ],
+ "fanTraySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303148,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0139885,
+ "configuredSpeed": 30,
+ "actualSpeed": 29,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9304729,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498939.9329433,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "2",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9383528,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140095,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "3/1",
+ }
+ ],
+ "speed": 30,
+ "label": "3",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303904,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140295,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "4/1",
+ }
+ ],
+ "speed": 30,
+ "label": "4",
+ },
+ ],
+ "minFanSpeed": 0,
+ "currentZones": 1,
+ "configuredZones": 0,
+ "systemStatus": "coolingOk",
+ }
+ ],
+ "inputs": {"states": ["ok"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-additional-states",
+ "test": VerifyEnvironmentCooling,
+ "eos_data": [
+ {
+ "defaultZones": False,
+ "numCoolingZones": [],
+ "coolingMode": "automatic",
+ "ambientTemperature": 24.5,
+ "shutdownOnInsufficientFans": True,
+ "airflowDirection": "frontToBackAirflow",
+ "overrideFanSpeed": 0,
+ "powerSupplySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498937.0240965,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499033.0403435,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "Not Inserted",
+ "uptime": 1682498935.9121106,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499092.4665174,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply2",
+ },
+ ],
+ "fanTraySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303148,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0139885,
+ "configuredSpeed": 30,
+ "actualSpeed": 29,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9304729,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498939.9329433,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "2",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9383528,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140095,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "3/1",
+ }
+ ],
+ "speed": 30,
+ "label": "3",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303904,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140295,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "4/1",
+ }
+ ],
+ "speed": 30,
+ "label": "4",
+ },
+ ],
+ "minFanSpeed": 0,
+ "currentZones": 1,
+ "configuredZones": 0,
+ "systemStatus": "coolingOk",
+ }
+ ],
+ "inputs": {"states": ["ok", "Not Inserted"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-fan-tray",
+ "test": VerifyEnvironmentCooling,
+ "eos_data": [
+ {
+ "defaultZones": False,
+ "numCoolingZones": [],
+ "coolingMode": "automatic",
+ "ambientTemperature": 24.5,
+ "shutdownOnInsufficientFans": True,
+ "airflowDirection": "frontToBackAirflow",
+ "overrideFanSpeed": 0,
+ "powerSupplySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498937.0240965,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499033.0403435,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498935.9121106,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499092.4665174,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply2",
+ },
+ ],
+ "fanTraySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "down",
+ "uptime": 1682498923.9303148,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0139885,
+ "configuredSpeed": 30,
+ "actualSpeed": 29,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9304729,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498939.9329433,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "2",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "Not Inserted",
+ "uptime": 1682498923.9383528,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140095,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "3/1",
+ }
+ ],
+ "speed": 30,
+ "label": "3",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303904,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140295,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "4/1",
+ }
+ ],
+ "speed": 30,
+ "label": "4",
+ },
+ ],
+ "minFanSpeed": 0,
+ "currentZones": 1,
+ "configuredZones": 0,
+ "systemStatus": "CoolingKo",
+ }
+ ],
+ "inputs": {"states": ["ok", "Not Inserted"]},
+ "expected": {"result": "failure", "messages": ["Fan 1/1 on Fan Tray 1 is: 'down'"]},
+ },
+ {
+ "name": "failure-power-supply",
+ "test": VerifyEnvironmentCooling,
+ "eos_data": [
+ {
+ "defaultZones": False,
+ "numCoolingZones": [],
+ "coolingMode": "automatic",
+ "ambientTemperature": 24.5,
+ "shutdownOnInsufficientFans": True,
+ "airflowDirection": "frontToBackAirflow",
+ "overrideFanSpeed": 0,
+ "powerSupplySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "down",
+ "uptime": 1682498937.0240965,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499033.0403435,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498935.9121106,
+ "maxSpeed": 23000,
+ "lastSpeedStableChangeTime": 1682499092.4665174,
+ "configuredSpeed": 30,
+ "actualSpeed": 33,
+ "speedHwOverride": True,
+ "speedStable": True,
+ "label": "PowerSupply2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "PowerSupply2",
+ },
+ ],
+ "fanTraySlots": [
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303148,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0139885,
+ "configuredSpeed": 30,
+ "actualSpeed": 29,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "1/1",
+ }
+ ],
+ "speed": 30,
+ "label": "1",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9304729,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498939.9329433,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "2/1",
+ }
+ ],
+ "speed": 30,
+ "label": "2",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "Not Inserted",
+ "uptime": 1682498923.9383528,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140095,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "3/1",
+ }
+ ],
+ "speed": 30,
+ "label": "3",
+ },
+ {
+ "status": "ok",
+ "fans": [
+ {
+ "status": "ok",
+ "uptime": 1682498923.9303904,
+ "maxSpeed": 17500,
+ "lastSpeedStableChangeTime": 1682498975.0140295,
+ "configuredSpeed": 30,
+ "actualSpeed": 30,
+ "speedHwOverride": False,
+ "speedStable": True,
+ "label": "4/1",
+ }
+ ],
+ "speed": 30,
+ "label": "4",
+ },
+ ],
+ "minFanSpeed": 0,
+ "currentZones": 1,
+ "configuredZones": 0,
+ "systemStatus": "CoolingKo",
+ }
+ ],
+ "inputs": {"states": ["ok", "Not Inserted"]},
+ "expected": {"result": "failure", "messages": ["Fan PowerSupply1/1 on PowerSupply PowerSupply1 is: 'down'"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyEnvironmentPower,
+ "eos_data": [
+ {
+ "powerSupplies": {
+ "1": {
+ "outputPower": 0.0,
+ "modelName": "PWR-500AC-F",
+ "capacity": 500.0,
+ "tempSensors": {
+ "TempSensorP1/2": {"status": "ok", "temperature": 0.0},
+ "TempSensorP1/3": {"status": "ok", "temperature": 0.0},
+ "TempSensorP1/1": {"status": "ok", "temperature": 0.0},
+ },
+ "fans": {"FanP1/1": {"status": "ok", "speed": 33}},
+ "state": "ok",
+ "inputCurrent": 0.0,
+ "dominant": False,
+ "inputVoltage": 0.0,
+ "outputCurrent": 0.0,
+ "managed": True,
+ },
+ "2": {
+ "outputPower": 117.375,
+ "uptime": 1682498935.9121966,
+ "modelName": "PWR-500AC-F",
+ "capacity": 500.0,
+ "tempSensors": {
+ "TempSensorP2/1": {"status": "ok", "temperature": 39.0},
+ "TempSensorP2/3": {"status": "ok", "temperature": 43.0},
+ "TempSensorP2/2": {"status": "ok", "temperature": 31.0},
+ },
+ "fans": {"FanP2/1": {"status": "ok", "speed": 33}},
+ "state": "ok",
+ "inputCurrent": 0.572265625,
+ "dominant": False,
+ "inputVoltage": 232.5,
+ "outputCurrent": 9.828125,
+ "managed": True,
+ },
+ }
+ }
+ ],
+ "inputs": {"states": ["ok"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-additional-states",
+ "test": VerifyEnvironmentPower,
+ "eos_data": [
+ {
+ "powerSupplies": {
+ "1": {
+ "outputPower": 0.0,
+ "modelName": "PWR-500AC-F",
+ "capacity": 500.0,
+ "tempSensors": {
+ "TempSensorP1/2": {"status": "ok", "temperature": 0.0},
+ "TempSensorP1/3": {"status": "ok", "temperature": 0.0},
+ "TempSensorP1/1": {"status": "ok", "temperature": 0.0},
+ },
+ "fans": {"FanP1/1": {"status": "ok", "speed": 33}},
+ "state": "Not Inserted",
+ "inputCurrent": 0.0,
+ "dominant": False,
+ "inputVoltage": 0.0,
+ "outputCurrent": 0.0,
+ "managed": True,
+ },
+ "2": {
+ "outputPower": 117.375,
+ "uptime": 1682498935.9121966,
+ "modelName": "PWR-500AC-F",
+ "capacity": 500.0,
+ "tempSensors": {
+ "TempSensorP2/1": {"status": "ok", "temperature": 39.0},
+ "TempSensorP2/3": {"status": "ok", "temperature": 43.0},
+ "TempSensorP2/2": {"status": "ok", "temperature": 31.0},
+ },
+ "fans": {"FanP2/1": {"status": "ok", "speed": 33}},
+ "state": "ok",
+ "inputCurrent": 0.572265625,
+ "dominant": False,
+ "inputVoltage": 232.5,
+ "outputCurrent": 9.828125,
+ "managed": True,
+ },
+ }
+ }
+ ],
+ "inputs": {"states": ["ok", "Not Inserted"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyEnvironmentPower,
+ "eos_data": [
+ {
+ "powerSupplies": {
+ "1": {
+ "outputPower": 0.0,
+ "modelName": "PWR-500AC-F",
+ "capacity": 500.0,
+ "tempSensors": {
+ "TempSensorP1/2": {"status": "ok", "temperature": 0.0},
+ "TempSensorP1/3": {"status": "ok", "temperature": 0.0},
+ "TempSensorP1/1": {"status": "ok", "temperature": 0.0},
+ },
+ "fans": {"FanP1/1": {"status": "ok", "speed": 33}},
+ "state": "powerLoss",
+ "inputCurrent": 0.0,
+ "dominant": False,
+ "inputVoltage": 0.0,
+ "outputCurrent": 0.0,
+ "managed": True,
+ },
+ "2": {
+ "outputPower": 117.375,
+ "uptime": 1682498935.9121966,
+ "modelName": "PWR-500AC-F",
+ "capacity": 500.0,
+ "tempSensors": {
+ "TempSensorP2/1": {"status": "ok", "temperature": 39.0},
+ "TempSensorP2/3": {"status": "ok", "temperature": 43.0},
+ "TempSensorP2/2": {"status": "ok", "temperature": 31.0},
+ },
+ "fans": {"FanP2/1": {"status": "ok", "speed": 33}},
+ "state": "ok",
+ "inputCurrent": 0.572265625,
+ "dominant": False,
+ "inputVoltage": 232.5,
+ "outputCurrent": 9.828125,
+ "managed": True,
+ },
+ }
+ }
+ ],
+ "inputs": {"states": ["ok"]},
+ "expected": {"result": "failure", "messages": ["The following power supplies status are not in the accepted states list: {'1': {'state': 'powerLoss'}}"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAdverseDrops,
+ "eos_data": [{"totalAdverseDrops": 0}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyAdverseDrops,
+ "eos_data": [{"totalAdverseDrops": 10}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device totalAdverseDrops counter is: '10'"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py
new file mode 100644
index 0000000..5b0d845
--- /dev/null
+++ b/tests/units/anta_tests/test_interfaces.py
@@ -0,0 +1,1411 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Test inputs for anta.tests.hardware"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.interfaces import (
+ VerifyIllegalLACP,
+ VerifyInterfaceDiscards,
+ VerifyInterfaceErrDisabled,
+ VerifyInterfaceErrors,
+ VerifyInterfaceIPv4,
+ VerifyInterfacesStatus,
+ VerifyInterfaceUtilization,
+ VerifyIPProxyARP,
+ VerifyIpVirtualRouterMac,
+ VerifyL2MTU,
+ VerifyL3MTU,
+ VerifyLoopbackCount,
+ VerifyPortChannels,
+ VerifyStormControlDrops,
+ VerifySVI,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyInterfaceUtilization,
+ "eos_data": [
+ """Port Name Intvl In Mbps % In Kpps Out Mbps % Out Kpps
+Et1 5:00 0.0 0.0% 0 0.0 0.0% 0
+Et4 5:00 0.0 0.0% 0 0.0 0.0% 0
+"""
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyInterfaceUtilization,
+ "eos_data": [
+ """Port Name Intvl In Mbps % In Kpps Out Mbps % Out Kpps
+Et1 5:00 0.0 0.0% 0 0.0 80.0% 0
+Et4 5:00 0.0 99.9% 0 0.0 0.0% 0
+"""
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["The following interfaces have a usage > 75%: {'Et1': '80.0%', 'Et4': '99.9%'}"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyInterfaceErrors,
+ "eos_data": [
+ {
+ "interfaceErrorCounters": {
+ "Ethernet1": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0},
+ "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0},
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-multiple-intfs",
+ "test": VerifyInterfaceErrors,
+ "eos_data": [
+ {
+ "interfaceErrorCounters": {
+ "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},
+ }
+ }
+ ],
+ "inputs": None,
+ "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}}]"
+ ],
+ },
+ },
+ {
+ "name": "failure-multiple-intfs-multiple-errors",
+ "test": VerifyInterfaceErrors,
+ "eos_data": [
+ {
+ "interfaceErrorCounters": {
+ "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},
+ }
+ }
+ ],
+ "inputs": None,
+ "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}}]"
+ ],
+ },
+ },
+ {
+ "name": "failure-single-intf-multiple-errors",
+ "test": VerifyInterfaceErrors,
+ "eos_data": [
+ {
+ "interfaceErrorCounters": {
+ "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 2, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0},
+ }
+ }
+ ],
+ "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}}]"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyInterfaceDiscards,
+ "eos_data": [
+ {
+ "inDiscardsTotal": 0,
+ "interfaces": {
+ "Ethernet2": {"outDiscards": 0, "inDiscards": 0},
+ "Ethernet1": {"outDiscards": 0, "inDiscards": 0},
+ },
+ "outDiscardsTotal": 0,
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyInterfaceDiscards,
+ "eos_data": [
+ {
+ "inDiscardsTotal": 0,
+ "interfaces": {
+ "Ethernet2": {"outDiscards": 42, "inDiscards": 0},
+ "Ethernet1": {"outDiscards": 0, "inDiscards": 42},
+ },
+ "outDiscardsTotal": 0,
+ }
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "The following interfaces have non 0 discard counter(s): [{'Ethernet2': {'outDiscards': 42, 'inDiscards': 0}},"
+ " {'Ethernet1': {'outDiscards': 0, 'inDiscards': 42}}]"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyInterfaceErrDisabled,
+ "eos_data": [
+ {
+ "interfaceStatuses": {
+ "Management1": {
+ "linkStatus": "connected",
+ },
+ "Ethernet8": {
+ "linkStatus": "connected",
+ },
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyInterfaceErrDisabled,
+ "eos_data": [
+ {
+ "interfaceStatuses": {
+ "Management1": {
+ "linkStatus": "errdisabled",
+ },
+ "Ethernet8": {
+ "linkStatus": "errdisabled",
+ },
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["The following interfaces are in error disabled state: ['Management1', 'Ethernet8']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Ethernet2", "status": "adminDown"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-up-with-line-protocol-status",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Ethernet8", "status": "up", "line_protocol_status": "down"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-with-line-protocol-status",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "testing"},
+ "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet3.10": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "dormant"},
+ }
+ }
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "status": "adminDown", "line_protocol_status": "down"},
+ {"name": "Ethernet8", "status": "adminDown", "line_protocol_status": "testing"},
+ {"name": "Ethernet3.10", "status": "down", "line_protocol_status": "dormant"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-lower",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "ethernet2", "status": "adminDown"}, {"name": "ethernet8", "status": "up"}, {"name": "ethernet3", "status": "up"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-eth-name",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "eth2", "status": "adminDown"}, {"name": "et8", "status": "up"}, {"name": "et3", "status": "up"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-po-name",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Port-Channel100": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "po100", "status": "up"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-sub-interfaces",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet52/1.1963": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Ethernet52/1.1963", "status": "up"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-transceiver-down",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet49/1": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "notPresent"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Ethernet49/1", "status": "adminDown"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-po-down",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Port-Channel100": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "lowerLayerDown"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "PortChannel100", "status": "adminDown"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-po-lowerlayerdown",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Port-Channel100": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "lowerLayerDown"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Port-Channel100", "status": "adminDown", "line_protocol_status": "lowerLayerDown"}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following interface(s) are not configured: ['Ethernet8']"],
+ },
+ },
+ {
+ "name": "failure-status-down",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following interface(s) are not in the expected state: ['Ethernet8 is down/down'"],
+ },
+ },
+ {
+ "name": "failure-proto-down",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "status": "up"},
+ {"name": "Ethernet8", "status": "up"},
+ {"name": "Ethernet3", "status": "up"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["The following interface(s) are not in the expected state: ['Ethernet8 is up/down'"],
+ },
+ },
+ {
+ "name": "failure-po-status-down",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Port-Channel100": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "lowerLayerDown"},
+ }
+ }
+ ],
+ "inputs": {"interfaces": [{"name": "PortChannel100", "status": "up"}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following interface(s) are not in the expected state: ['Port-Channel100 is down/lowerLayerDown'"],
+ },
+ },
+ {
+ "name": "failure-proto-unknown",
+ "test": VerifyInterfacesStatus,
+ "eos_data": [
+ {
+ "interfaceDescriptions": {
+ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"},
+ "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "unknown"},
+ "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"},
+ }
+ }
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "status": "up", "line_protocol_status": "down"},
+ {"name": "Ethernet8", "status": "up"},
+ {"name": "Ethernet3", "status": "up"},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["The following interface(s) are not in the expected state: ['Ethernet2 is up/unknown'"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyStormControlDrops,
+ "eos_data": [
+ {
+ "aggregateTrafficClasses": {},
+ "interfaces": {
+ "Ethernet1": {
+ "trafficTypes": {"broadcast": {"level": 100, "thresholdType": "packetsPerSecond", "rate": 0, "drop": 0, "dormant": False}},
+ "active": True,
+ "reason": "",
+ "errdisabled": False,
+ }
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyStormControlDrops,
+ "eos_data": [
+ {
+ "aggregateTrafficClasses": {},
+ "interfaces": {
+ "Ethernet1": {
+ "trafficTypes": {"broadcast": {"level": 100, "thresholdType": "packetsPerSecond", "rate": 0, "drop": 666, "dormant": False}},
+ "active": True,
+ "reason": "",
+ "errdisabled": False,
+ }
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["The following interfaces have none 0 storm-control drop counters {'Ethernet1': {'broadcast': 666}}"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyPortChannels,
+ "eos_data": [
+ {
+ "portChannels": {
+ "Port-Channel42": {
+ "recircFeature": [],
+ "maxWeight": 16,
+ "minSpeed": "0 gbps",
+ "rxPorts": {},
+ "currWeight": 0,
+ "minLinks": 0,
+ "inactivePorts": {},
+ "activePorts": {},
+ "inactiveLag": False,
+ }
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyPortChannels,
+ "eos_data": [
+ {
+ "portChannels": {
+ "Port-Channel42": {
+ "recircFeature": [],
+ "maxWeight": 16,
+ "minSpeed": "0 gbps",
+ "rxPorts": {},
+ "currWeight": 0,
+ "minLinks": 0,
+ "inactivePorts": {"Ethernet8": {"reasonUnconfigured": "waiting for LACP response"}},
+ "activePorts": {},
+ "inactiveLag": False,
+ }
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["The following port-channels have inactive port(s): ['Port-Channel42']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyIllegalLACP,
+ "eos_data": [
+ {
+ "portChannels": {
+ "Port-Channel42": {
+ "interfaces": {
+ "Ethernet8": {
+ "actorPortStatus": "noAgg",
+ "illegalRxCount": 0,
+ "markerResponseTxCount": 0,
+ "markerResponseRxCount": 0,
+ "lacpdusRxCount": 0,
+ "lacpdusTxCount": 454,
+ "markersTxCount": 0,
+ "markersRxCount": 0,
+ }
+ }
+ }
+ },
+ "orphanPorts": {},
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyIllegalLACP,
+ "eos_data": [
+ {
+ "portChannels": {
+ "Port-Channel42": {
+ "interfaces": {
+ "Ethernet8": {
+ "actorPortStatus": "noAgg",
+ "illegalRxCount": 666,
+ "markerResponseTxCount": 0,
+ "markerResponseRxCount": 0,
+ "lacpdusRxCount": 0,
+ "lacpdusTxCount": 454,
+ "markersTxCount": 0,
+ "markersRxCount": 0,
+ }
+ }
+ }
+ },
+ "orphanPorts": {},
+ }
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": ["The following port-channels have recieved illegal lacp packets on the following ports: [{'Port-Channel42': 'Ethernet8'}]"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyLoopbackCount,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Loopback42": {
+ "name": "Loopback42",
+ "interfaceStatus": "connected",
+ "interfaceAddress": {"ipAddr": {"maskLen": 0, "address": "0.0.0.0"}, "unnumberedIntf": "Vlan42"},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "up",
+ "mtu": 65535,
+ },
+ "Loopback666": {
+ "name": "Loopback666",
+ "interfaceStatus": "connected",
+ "interfaceAddress": {"ipAddr": {"maskLen": 32, "address": "6.6.6.6"}},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "up",
+ "mtu": 65535,
+ },
+ }
+ }
+ ],
+ "inputs": {"number": 2},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-loopback-down",
+ "test": VerifyLoopbackCount,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Loopback42": {
+ "name": "Loopback42",
+ "interfaceStatus": "connected",
+ "interfaceAddress": {"ipAddr": {"maskLen": 0, "address": "0.0.0.0"}, "unnumberedIntf": "Vlan42"},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "up",
+ "mtu": 65535,
+ },
+ "Loopback666": {
+ "name": "Loopback666",
+ "interfaceStatus": "connected",
+ "interfaceAddress": {"ipAddr": {"maskLen": 32, "address": "6.6.6.6"}},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "down",
+ "mtu": 65535,
+ },
+ }
+ }
+ ],
+ "inputs": {"number": 2},
+ "expected": {"result": "failure", "messages": ["The following Loopbacks are not up: ['Loopback666']"]},
+ },
+ {
+ "name": "failure-count-loopback",
+ "test": VerifyLoopbackCount,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Loopback42": {
+ "name": "Loopback42",
+ "interfaceStatus": "connected",
+ "interfaceAddress": {"ipAddr": {"maskLen": 0, "address": "0.0.0.0"}, "unnumberedIntf": "Vlan42"},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "up",
+ "mtu": 65535,
+ },
+ }
+ }
+ ],
+ "inputs": {"number": 2},
+ "expected": {"result": "failure", "messages": ["Found 1 Loopbacks when expecting 2"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySVI,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Vlan42": {
+ "name": "Vlan42",
+ "interfaceStatus": "connected",
+ "interfaceAddress": {"ipAddr": {"maskLen": 24, "address": "11.11.11.11"}},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "up",
+ "mtu": 1500,
+ }
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifySVI,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Vlan42": {
+ "name": "Vlan42",
+ "interfaceStatus": "notconnect",
+ "interfaceAddress": {"ipAddr": {"maskLen": 24, "address": "11.11.11.11"}},
+ "ipv4Routable240": False,
+ "lineProtocolStatus": "lowerLayerDown",
+ "mtu": 1500,
+ }
+ }
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["The following SVIs are not up: ['Vlan42']"]},
+ },
+ {
+ "name": "success",
+ "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": "bridged",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1500,
+ "l3MtuConfigured": False,
+ "l2Mru": 0,
+ },
+ "Management1/1": {
+ "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},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success",
+ "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": 1501,
+ "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": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyL3MTU,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "name": "Ethernet2",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1600,
+ "l3MtuConfigured": True,
+ "l2Mru": 0,
+ },
+ "Ethernet10": {
+ "name": "Ethernet10",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1500,
+ "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},
+ "expected": {"result": "failure", "messages": ["Some interfaces do not have correct MTU configured:\n[{'Ethernet2': 1600}]"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyL2MTU,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "name": "Ethernet2",
+ "forwardingModel": "routed",
+ "lineProtocolStatus": "up",
+ "interfaceStatus": "connected",
+ "hardware": "ethernet",
+ "mtu": 1500,
+ "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": {"mtu": 9214},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "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": {"mtu": 1500},
+ "expected": {"result": "failure", "messages": ["Some L2 interfaces do not have correct MTU configured:\n[{'Ethernet10': 9214}, {'Port-Channel2': 9214}]"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyIPProxyARP,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet1": {
+ "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,
+ }
+ }
+ },
+ ],
+ "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyIPProxyARP,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet1": {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "interfaceAddress": {
+ "primaryIp": {"address": "172.30.11.0", "maskLen": 31},
+ "secondaryIpsOrderedList": [{"address": "10.10.10.0", "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}],
+ }
+ }
+ }
+ },
+ ],
+ "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.10/31", "secondary_ips": ["10.10.10.10/31", "10.10.10.20/31"]},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-without-secondary-ip",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "interfaceAddress": {
+ "primaryIp": {"address": "172.30.11.0", "maskLen": 31},
+ "secondaryIpsOrderedList": [],
+ }
+ }
+ }
+ },
+ {
+ "interfaces": {
+ "Ethernet12": {
+ "interfaceAddress": {
+ "primaryIp": {"address": "172.30.11.10", "maskLen": 31},
+ "secondaryIpsOrderedList": [],
+ }
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "primary_ip": "172.30.11.0/31"},
+ {"name": "Ethernet12", "primary_ip": "172.30.11.10/31"},
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-l3-interface",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [{"interfaces": {"Ethernet2": {"interfaceAddress": {}}}}, {"interfaces": {"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": ["For interface `Ethernet2`, IP address is not configured.", "For interface `Ethernet12`, IP address is not configured."],
+ },
+ },
+ {
+ "name": "failure-ip-address-not-configured",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "interfaceAddress": {
+ "primaryIp": {"address": "0.0.0.0", "maskLen": 0},
+ "secondaryIpsOrderedList": [],
+ }
+ }
+ }
+ },
+ {
+ "interfaces": {
+ "Ethernet12": {
+ "interfaceAddress": {
+ "primaryIp": {"address": "0.0.0.0", "maskLen": 0},
+ "secondaryIpsOrderedList": [],
+ }
+ }
+ }
+ },
+ ],
+ "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.10/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]},
+ ]
+ },
+ "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.",
+ ],
+ },
+ },
+ {
+ "name": "failure-ip-address-missmatch",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "interfaceAddress": {
+ "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}],
+ }
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "primary_ip": "172.30.11.2/31", "secondary_ips": ["10.10.10.20/31", "10.10.10.30/31"]},
+ {"name": "Ethernet3", "primary_ip": "172.30.10.2/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]},
+ ]
+ },
+ "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']`.",
+ ],
+ },
+ },
+ {
+ "name": "failure-secondary-ip-address",
+ "test": VerifyInterfaceIPv4,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet2": {
+ "interfaceAddress": {
+ "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}],
+ }
+ }
+ }
+ },
+ ],
+ "inputs": {
+ "interfaces": [
+ {"name": "Ethernet2", "primary_ip": "172.30.11.2/31", "secondary_ips": ["10.10.10.20/31", "10.10.10.30/31"]},
+ {"name": "Ethernet3", "primary_ip": "172.30.10.2/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]},
+ ]
+ },
+ "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']`.",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyIpVirtualRouterMac,
+ "eos_data": [
+ {
+ "virtualMacs": [
+ {
+ "macAddress": "00:1c:73:00:dc:01",
+ }
+ ],
+ }
+ ],
+ "inputs": {"mac_address": "00:1c:73:00:dc:01"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "faliure-incorrect-mac-address",
+ "test": VerifyIpVirtualRouterMac,
+ "eos_data": [
+ {
+ "virtualMacs": [
+ {
+ "macAddress": "00:00:00:00:00:00",
+ }
+ ],
+ }
+ ],
+ "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."]},
+ },
+]
diff --git a/tests/units/anta_tests/test_lanz.py b/tests/units/anta_tests/test_lanz.py
new file mode 100644
index 0000000..932d1ac
--- /dev/null
+++ b/tests/units/anta_tests/test_lanz.py
@@ -0,0 +1,27 @@
+# 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.
+"""Data for testing anta.tests.configuration"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.lanz import VerifyLANZ
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyLANZ,
+ "eos_data": [{"lanzEnabled": True}],
+ "inputs": None,
+ "expected": {"result": "success", "messages": ["LANZ is enabled"]},
+ },
+ {
+ "name": "failure",
+ "test": VerifyLANZ,
+ "eos_data": [{"lanzEnabled": False}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["LANZ is not enabled"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py
new file mode 100644
index 0000000..8ac2323
--- /dev/null
+++ b/tests/units/anta_tests/test_logging.py
@@ -0,0 +1,254 @@
+# 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.
+"""Data for testing anta.tests.logging"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.logging import (
+ VerifyLoggingAccounting,
+ VerifyLoggingErrors,
+ VerifyLoggingHostname,
+ VerifyLoggingHosts,
+ VerifyLoggingLogsGeneration,
+ VerifyLoggingPersistent,
+ VerifyLoggingSourceIntf,
+ VerifyLoggingTimestamp,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyLoggingPersistent,
+ "eos_data": [
+ "Persistent logging: level debugging\n",
+ """Directory of flash:/persist/messages
+
+ -rw- 9948 May 10 13:54 messages
+
+ 33214693376 bytes total (10081136640 bytes free)
+
+ """,
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-disabled",
+ "test": VerifyLoggingPersistent,
+ "eos_data": [
+ "Persistent logging: disabled\n",
+ """Directory of flash:/persist/messages
+
+ -rw- 0 Apr 13 16:29 messages
+
+ 33214693376 bytes total (10082168832 bytes free)
+
+ """,
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Persistent logging is disabled"]},
+ },
+ {
+ "name": "failure-not-saved",
+ "test": VerifyLoggingPersistent,
+ "eos_data": [
+ "Persistent logging: level debugging\n",
+ """Directory of flash:/persist/messages
+
+ -rw- 0 Apr 13 16:29 messages
+
+ 33214693376 bytes total (10082168832 bytes free)
+
+ """,
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["No persistent logs are saved in flash"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingSourceIntf,
+ "eos_data": [
+ """Trap logging: level informational
+ Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF MGMT
+ Logging to '10.22.10.92' port 514 in VRF MGMT via udp
+ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp
+ Logging to '10.22.10.94' port 911 in VRF MGMT via udp
+
+ """
+ ],
+ "inputs": {"interface": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-intf",
+ "test": VerifyLoggingSourceIntf,
+ "eos_data": [
+ """Trap logging: level informational
+ Logging source-interface 'Management1', IP Address 172.20.20.12 in VRF MGMT
+ Logging to '10.22.10.92' port 514 in VRF MGMT via udp
+ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp
+ Logging to '10.22.10.94' port 911 in VRF MGMT via udp
+
+ """
+ ],
+ "inputs": {"interface": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]},
+ },
+ {
+ "name": "failure-vrf",
+ "test": VerifyLoggingSourceIntf,
+ "eos_data": [
+ """Trap logging: level informational
+ Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF default
+ Logging to '10.22.10.92' port 514 in VRF MGMT via udp
+ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp
+ Logging to '10.22.10.94' port 911 in VRF MGMT via udp
+
+ """
+ ],
+ "inputs": {"interface": "Management0", "vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingHosts,
+ "eos_data": [
+ """Trap logging: level informational
+ Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF MGMT
+ Logging to '10.22.10.92' port 514 in VRF MGMT via udp
+ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp
+ Logging to '10.22.10.94' port 911 in VRF MGMT via udp
+
+ """
+ ],
+ "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-hosts",
+ "test": VerifyLoggingHosts,
+ "eos_data": [
+ """Trap logging: level informational
+ Logging source-interface 'Management1', IP Address 172.20.20.12 in VRF MGMT
+ Logging to '10.22.10.92' port 514 in VRF MGMT via udp
+ Logging to '10.22.10.103' port 514 in VRF MGMT via tcp
+ Logging to '10.22.10.104' port 911 in VRF MGMT via udp
+
+ """
+ ],
+ "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"]},
+ },
+ {
+ "name": "failure-vrf",
+ "test": VerifyLoggingHosts,
+ "eos_data": [
+ """Trap logging: level informational
+ Logging source-interface 'Management0', IP Address 172.20.20.12 in VRF MGMT
+ Logging to '10.22.10.92' port 514 in VRF MGMT via udp
+ Logging to '10.22.10.93' port 514 in VRF default via tcp
+ Logging to '10.22.10.94' port 911 in VRF default via udp
+
+ """
+ ],
+ "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"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingLogsGeneration,
+ "eos_data": [
+ "",
+ "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,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyLoggingLogsGeneration,
+ "eos_data": ["", "Log Buffer:\n"],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Logs are not generated"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingHostname,
+ "eos_data": [
+ {"hostname": "NW-CORE", "fqdn": "NW-CORE.example.org"},
+ "",
+ "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,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyLoggingHostname,
+ "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: "
+ "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingLogsHostname validation\n",
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Logs are not generated with the device FQDN"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingTimestamp,
+ "eos_data": [
+ "",
+ "2023-05-10T15:41:44.680813-05:00 NW-CORE.example.org ConfigAgent: %SYS-6-LOGMSG_INFO: "
+ "Message from arista on command-api (10.22.1.107): ANTA VerifyLoggingTimestamp validation\n",
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "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): ANTA VerifyLoggingTimestamp validation\n",
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Logs are not generated with the appropriate timestamp format"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingAccounting,
+ "eos_data": ["2023 May 10 15:50:31 arista command-api 10.22.1.107 stop service=shell priv-lvl=15 cmd=show aaa accounting logs | tail\n"],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyLoggingAccounting,
+ "eos_data": ["2023 May 10 15:52:26 arista vty14 10.22.1.107 stop service=shell priv-lvl=15 cmd=show bgp summary\n"],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["AAA accounting logs are not generated"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyLoggingErrors,
+ "eos_data": [""],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyLoggingErrors,
+ "eos_data": [
+ "Aug 2 19:57:42 DC1-LEAF1A Mlag: %FWK-3-SOCKET_CLOSE_REMOTE: Connection to Mlag (pid:27200) at tbt://192.168.0.1:4432/+n closed by peer (EOF)"
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device has reported syslog messages with a severity of ERRORS or higher"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py
new file mode 100644
index 0000000..90f3c7a
--- /dev/null
+++ b/tests/units/anta_tests/test_mlag.py
@@ -0,0 +1,343 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.mlag.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.mlag import VerifyMlagConfigSanity, VerifyMlagDualPrimary, VerifyMlagInterfaces, VerifyMlagPrimaryPriority, VerifyMlagReloadDelay, VerifyMlagStatus
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyMlagStatus,
+ "eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "up", "localIntfStatus": "up"}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped",
+ "test": VerifyMlagStatus,
+ "eos_data": [
+ {
+ "state": "disabled",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
+ },
+ {
+ "name": "failure",
+ "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'}"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyMlagInterfaces,
+ "eos_data": [
+ {
+ "state": "active",
+ "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 0, "Active-partial": 0, "Active-full": 1},
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped",
+ "test": VerifyMlagInterfaces,
+ "eos_data": [
+ {
+ "state": "disabled",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
+ },
+ {
+ "name": "failure-active-partial",
+ "test": VerifyMlagInterfaces,
+ "eos_data": [
+ {
+ "state": "active",
+ "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 0, "Active-partial": 1, "Active-full": 1},
+ }
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 0, 'Active-partial': 1, 'Active-full': 1}"],
+ },
+ },
+ {
+ "name": "failure-inactive",
+ "test": VerifyMlagInterfaces,
+ "eos_data": [
+ {
+ "state": "active",
+ "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 1, "Active-partial": 1, "Active-full": 1},
+ }
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 1, 'Active-partial': 1, 'Active-full': 1}"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyMlagConfigSanity,
+ "eos_data": [{"globalConfiguration": {}, "interfaceConfiguration": {}, "mlagActive": True, "mlagConnected": True}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped",
+ "test": VerifyMlagConfigSanity,
+ "eos_data": [
+ {
+ "mlagActive": False,
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
+ },
+ {
+ "name": "error",
+ "test": VerifyMlagConfigSanity,
+ "eos_data": [
+ {
+ "dummy": False,
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "error", "messages": ["Incorrect JSON response - 'mlagActive' state was not found"]},
+ },
+ {
+ "name": "failure-global",
+ "test": VerifyMlagConfigSanity,
+ "eos_data": [
+ {
+ "globalConfiguration": {"mlag": {"globalParameters": {"dual-primary-detection-delay": {"localValue": "0", "peerValue": "200"}}}},
+ "interfaceConfiguration": {},
+ "mlagActive": True,
+ "mlagConnected": True,
+ }
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "MLAG config-sanity returned inconsistencies: "
+ "{'globalConfiguration': {'mlag': {'globalParameters': "
+ "{'dual-primary-detection-delay': {'localValue': '0', 'peerValue': '200'}}}}, "
+ "'interfaceConfiguration': {}}"
+ ],
+ },
+ },
+ {
+ "name": "failure-interface",
+ "test": VerifyMlagConfigSanity,
+ "eos_data": [
+ {
+ "globalConfiguration": {},
+ "interfaceConfiguration": {"trunk-native-vlan mlag30": {"interface": {"Port-Channel30": {"localValue": "123", "peerValue": "3700"}}}},
+ "mlagActive": True,
+ "mlagConnected": True,
+ }
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "MLAG config-sanity returned inconsistencies: "
+ "{'globalConfiguration': {}, "
+ "'interfaceConfiguration': {'trunk-native-vlan mlag30': "
+ "{'interface': {'Port-Channel30': {'localValue': '123', 'peerValue': '3700'}}}}}"
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyMlagReloadDelay,
+ "eos_data": [{"state": "active", "reloadDelay": 300, "reloadDelayNonMlag": 330}],
+ "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped-disabled",
+ "test": VerifyMlagReloadDelay,
+ "eos_data": [
+ {
+ "state": "disabled",
+ }
+ ],
+ "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330},
+ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
+ },
+ {
+ "name": "failure",
+ "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}"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyMlagDualPrimary,
+ "eos_data": [
+ {
+ "state": "active",
+ "dualPrimaryDetectionState": "configured",
+ "dualPrimaryPortsErrdisabled": False,
+ "dualPrimaryMlagRecoveryDelay": 60,
+ "dualPrimaryNonMlagRecoveryDelay": 0,
+ "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "none"},
+ }
+ ],
+ "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped-disabled",
+ "test": VerifyMlagDualPrimary,
+ "eos_data": [
+ {
+ "state": "disabled",
+ }
+ ],
+ "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0},
+ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
+ },
+ {
+ "name": "failure-disabled",
+ "test": VerifyMlagDualPrimary,
+ "eos_data": [
+ {
+ "state": "active",
+ "dualPrimaryDetectionState": "disabled",
+ "dualPrimaryPortsErrdisabled": False,
+ }
+ ],
+ "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0},
+ "expected": {"result": "failure", "messages": ["Dual-primary detection is disabled"]},
+ },
+ {
+ "name": "failure-wrong-timers",
+ "test": VerifyMlagDualPrimary,
+ "eos_data": [
+ {
+ "state": "active",
+ "dualPrimaryDetectionState": "configured",
+ "dualPrimaryPortsErrdisabled": False,
+ "dualPrimaryMlagRecoveryDelay": 160,
+ "dualPrimaryNonMlagRecoveryDelay": 0,
+ "detail": {"dualPrimaryDetectionDelay": 300, "dualPrimaryAction": "none"},
+ }
+ ],
+ "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0},
+ "expected": {
+ "result": "failure",
+ "messages": [
+ (
+ "The dual-primary parameters are not configured properly: "
+ "{'detail.dualPrimaryDetectionDelay': 300, "
+ "'detail.dualPrimaryAction': 'none', "
+ "'dualPrimaryMlagRecoveryDelay': 160, "
+ "'dualPrimaryNonMlagRecoveryDelay': 0}"
+ )
+ ],
+ },
+ },
+ {
+ "name": "failure-wrong-action",
+ "test": VerifyMlagDualPrimary,
+ "eos_data": [
+ {
+ "state": "active",
+ "dualPrimaryDetectionState": "configured",
+ "dualPrimaryPortsErrdisabled": False,
+ "dualPrimaryMlagRecoveryDelay": 60,
+ "dualPrimaryNonMlagRecoveryDelay": 0,
+ "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "none"},
+ }
+ ],
+ "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}"
+ )
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyMlagPrimaryPriority,
+ "eos_data": [
+ {
+ "state": "active",
+ "detail": {"mlagState": "primary", "primaryPriority": 32767},
+ }
+ ],
+ "inputs": {
+ "primary_priority": 32767,
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped-disabled",
+ "test": VerifyMlagPrimaryPriority,
+ "eos_data": [
+ {
+ "state": "disabled",
+ }
+ ],
+ "inputs": {"primary_priority": 32767},
+ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]},
+ },
+ {
+ "name": "failure-not-primary",
+ "test": VerifyMlagPrimaryPriority,
+ "eos_data": [
+ {
+ "state": "active",
+ "detail": {"mlagState": "secondary", "primaryPriority": 32767},
+ }
+ ],
+ "inputs": {"primary_priority": 32767},
+ "expected": {
+ "result": "failure",
+ "messages": ["The device is not set as MLAG primary."],
+ },
+ },
+ {
+ "name": "failure-incorrect-priority",
+ "test": VerifyMlagPrimaryPriority,
+ "eos_data": [
+ {
+ "state": "active",
+ "detail": {"mlagState": "secondary", "primaryPriority": 32767},
+ }
+ ],
+ "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."],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_multicast.py b/tests/units/anta_tests/test_multicast.py
new file mode 100644
index 0000000..9276a9f
--- /dev/null
+++ b/tests/units/anta_tests/test_multicast.py
@@ -0,0 +1,175 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Test inputs for anta.tests.multicast"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.multicast import VerifyIGMPSnoopingGlobal, VerifyIGMPSnoopingVlans
+from tests.lib.anta import test # noqa: F401; pylint: disable=unused-import
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success-enabled",
+ "test": VerifyIGMPSnoopingVlans,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "enabled",
+ "vlans": {
+ "1": {
+ "reportFlooding": "disabled",
+ "proxyActive": False,
+ "groupsOverrun": False,
+ "multicastRouterLearningMode": "pim-dvmrp",
+ "igmpSnoopingState": "enabled",
+ "pruningActive": False,
+ "maxGroups": 65534,
+ "immediateLeave": "default",
+ "floodingTraffic": True,
+ },
+ "42": {
+ "reportFlooding": "disabled",
+ "proxyActive": False,
+ "groupsOverrun": False,
+ "multicastRouterLearningMode": "pim-dvmrp",
+ "igmpSnoopingState": "enabled",
+ "pruningActive": False,
+ "maxGroups": 65534,
+ "immediateLeave": "default",
+ "floodingTraffic": True,
+ },
+ },
+ "robustness": 2,
+ "immediateLeave": "enabled",
+ "reportFloodingSwitchPorts": [],
+ }
+ ],
+ "inputs": {"vlans": {1: True, 42: True}},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-disabled",
+ "test": VerifyIGMPSnoopingVlans,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "enabled",
+ "vlans": {
+ "42": {
+ "reportFlooding": "disabled",
+ "proxyActive": False,
+ "groupsOverrun": False,
+ "multicastRouterLearningMode": "pim-dvmrp",
+ "igmpSnoopingState": "disabled",
+ "pruningActive": False,
+ "maxGroups": 65534,
+ "immediateLeave": "default",
+ "floodingTraffic": True,
+ }
+ },
+ "robustness": 2,
+ "immediateLeave": "enabled",
+ "reportFloodingSwitchPorts": [],
+ }
+ ],
+ "inputs": {"vlans": {42: False}},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-missing-vlan",
+ "test": VerifyIGMPSnoopingVlans,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "enabled",
+ "vlans": {
+ "1": {
+ "reportFlooding": "disabled",
+ "proxyActive": False,
+ "groupsOverrun": False,
+ "multicastRouterLearningMode": "pim-dvmrp",
+ "igmpSnoopingState": "enabled",
+ "pruningActive": False,
+ "maxGroups": 65534,
+ "immediateLeave": "default",
+ "floodingTraffic": True,
+ },
+ },
+ "robustness": 2,
+ "immediateLeave": "enabled",
+ "reportFloodingSwitchPorts": [],
+ }
+ ],
+ "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."]},
+ },
+ {
+ "name": "failure-wrong-state",
+ "test": VerifyIGMPSnoopingVlans,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "enabled",
+ "vlans": {
+ "1": {
+ "reportFlooding": "disabled",
+ "proxyActive": False,
+ "groupsOverrun": False,
+ "multicastRouterLearningMode": "pim-dvmrp",
+ "igmpSnoopingState": "disabled",
+ "pruningActive": False,
+ "maxGroups": 65534,
+ "immediateLeave": "default",
+ "floodingTraffic": True,
+ },
+ },
+ "robustness": 2,
+ "immediateLeave": "enabled",
+ "reportFloodingSwitchPorts": [],
+ }
+ ],
+ "inputs": {"vlans": {1: True}},
+ "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is disabled"]},
+ },
+ {
+ "name": "success-enabled",
+ "test": VerifyIGMPSnoopingGlobal,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "enabled",
+ "robustness": 2,
+ "immediateLeave": "enabled",
+ "reportFloodingSwitchPorts": [],
+ }
+ ],
+ "inputs": {"enabled": True},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-disabled",
+ "test": VerifyIGMPSnoopingGlobal,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "disabled",
+ }
+ ],
+ "inputs": {"enabled": False},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-state",
+ "test": VerifyIGMPSnoopingGlobal,
+ "eos_data": [
+ {
+ "reportFlooding": "disabled",
+ "igmpSnoopingState": "disabled",
+ }
+ ],
+ "inputs": {"enabled": True},
+ "expected": {"result": "failure", "messages": ["IGMP state is not valid: disabled"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_profiles.py b/tests/units/anta_tests/test_profiles.py
new file mode 100644
index 0000000..c0ebb57
--- /dev/null
+++ b/tests/units/anta_tests/test_profiles.py
@@ -0,0 +1,47 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.profiles.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.profiles import VerifyTcamProfile, VerifyUnifiedForwardingTableMode
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyUnifiedForwardingTableMode,
+ "eos_data": [{"uftMode": "2", "urpfEnabled": False, "chipModel": "bcm56870", "l2TableSize": 163840, "l3TableSize": 147456, "lpmTableSize": 32768}],
+ "inputs": {"mode": 2},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "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)"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTcamProfile,
+ "eos_data": [
+ {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "test", "mode": "tcam"}}, "lastProgrammingStatus": {}}
+ ],
+ "inputs": {"profile": "test"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyTcamProfile,
+ "eos_data": [
+ {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "default", "mode": "tcam"}}, "lastProgrammingStatus": {}}
+ ],
+ "inputs": {"profile": "test"},
+ "expected": {"result": "failure", "messages": ["Incorrect profile running on device: default"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_ptp.py b/tests/units/anta_tests/test_ptp.py
new file mode 100644
index 0000000..3969c97
--- /dev/null
+++ b/tests/units/anta_tests/test_ptp.py
@@ -0,0 +1,42 @@
+# 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.
+"""Data for testing anta.tests.configuration"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.ptp import VerifyPtpStatus
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyPtpStatus,
+ "eos_data": [
+ {
+ "ptpMode": "ptpBoundaryClock",
+ "ptpProfile": "ptpDefaultProfile",
+ "ptpClockSummary": {
+ "clockIdentity": "0xcc:1a:a3:ff:ff:c3:bf:eb",
+ "gmClockIdentity": "0x00:00:00:00:00:00:00:00",
+ "numberOfSlavePorts": 0,
+ "numberOfMasterPorts": 0,
+ "offsetFromMaster": 0,
+ "meanPathDelay": 0,
+ "stepsRemoved": 0,
+ "skew": 1.0,
+ },
+ "ptpIntfSummaries": {},
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyPtpStatus,
+ "eos_data": [{"ptpIntfSummaries": {}}],
+ "inputs": None,
+ "expected": {"result": "failure"},
+ },
+]
diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py
new file mode 100644
index 0000000..17fa04e
--- /dev/null
+++ b/tests/units/anta_tests/test_security.py
@@ -0,0 +1,900 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.security.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.security import (
+ VerifyAPIHttpsSSL,
+ VerifyAPIHttpStatus,
+ VerifyAPIIPv4Acl,
+ VerifyAPIIPv6Acl,
+ VerifyAPISSLCertificate,
+ VerifyBannerLogin,
+ VerifyBannerMotd,
+ VerifyIPv4ACL,
+ VerifySSHIPv4Acl,
+ VerifySSHIPv6Acl,
+ VerifySSHStatus,
+ VerifyTelnetStatus,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifySSHStatus,
+ "eos_data": ["SSHD status for Default VRF is disabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifySSHStatus,
+ "eos_data": ["SSHD status for Default VRF is enabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["SSHD status for Default VRF is enabled"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySSHIPv4Acl,
+ "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SSH", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}],
+ "inputs": {"number": 1, "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-number",
+ "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"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySSHIPv6Acl,
+ "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SSH", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}],
+ "inputs": {"number": 1, "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-number",
+ "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"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTelnetStatus,
+ "eos_data": [{"serverState": "disabled", "vrfName": "default", "maxTelnetSessions": 20, "maxTelnetSessionsPerHost": 20}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyTelnetStatus,
+ "eos_data": [{"serverState": "enabled", "vrfName": "default", "maxTelnetSessions": 20, "maxTelnetSessionsPerHost": 20}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Telnet status for Default VRF is enabled"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAPIHttpStatus,
+ "eos_data": [
+ {
+ "enabled": True,
+ "httpServer": {"configured": False, "running": False, "port": 80},
+ "localHttpServer": {"configured": False, "running": False, "port": 8080},
+ "httpsServer": {"configured": True, "running": True, "port": 443},
+ "unixSocketServer": {"configured": False, "running": False},
+ "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"},
+ "tlsProtocol": ["1.2"],
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyAPIHttpStatus,
+ "eos_data": [
+ {
+ "enabled": True,
+ "httpServer": {"configured": True, "running": True, "port": 80},
+ "localHttpServer": {"configured": False, "running": False, "port": 8080},
+ "httpsServer": {"configured": True, "running": True, "port": 443},
+ "unixSocketServer": {"configured": False, "running": False},
+ "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"},
+ "tlsProtocol": ["1.2"],
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["eAPI HTTP server is enabled globally"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAPIHttpsSSL,
+ "eos_data": [
+ {
+ "enabled": True,
+ "httpServer": {"configured": False, "running": False, "port": 80},
+ "localHttpServer": {"configured": False, "running": False, "port": 8080},
+ "httpsServer": {"configured": True, "running": True, "port": 443},
+ "unixSocketServer": {"configured": False, "running": False},
+ "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"},
+ "tlsProtocol": ["1.2"],
+ }
+ ],
+ "inputs": {"profile": "API_SSL_Profile"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-not-configured",
+ "test": VerifyAPIHttpsSSL,
+ "eos_data": [
+ {
+ "enabled": True,
+ "httpServer": {"configured": True, "running": True, "port": 80},
+ "localHttpServer": {"configured": False, "running": False, "port": 8080},
+ "httpsServer": {"configured": True, "running": True, "port": 443},
+ "unixSocketServer": {"configured": False, "running": False},
+ "tlsProtocol": ["1.2"],
+ }
+ ],
+ "inputs": {"profile": "API_SSL_Profile"},
+ "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is not configured"]},
+ },
+ {
+ "name": "failure-misconfigured-invalid",
+ "test": VerifyAPIHttpsSSL,
+ "eos_data": [
+ {
+ "enabled": True,
+ "httpServer": {"configured": True, "running": True, "port": 80},
+ "localHttpServer": {"configured": False, "running": False, "port": 8080},
+ "httpsServer": {"configured": True, "running": True, "port": 443},
+ "unixSocketServer": {"configured": False, "running": False},
+ "sslProfile": {"name": "Wrong_SSL_Profile", "configured": True, "state": "valid"},
+ "tlsProtocol": ["1.2"],
+ }
+ ],
+ "inputs": {"profile": "API_SSL_Profile"},
+ "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is misconfigured or invalid"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAPIIPv4Acl,
+ "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_API", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}],
+ "inputs": {"number": 1, "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-number",
+ "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"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAPIIPv6Acl,
+ "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_API", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}],
+ "inputs": {"number": 1, "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-number",
+ "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"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_ROOT_CA.crt": {
+ "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "RSA",
+ "size": 4096,
+ },
+ },
+ "ARISTA_SIGNING_CA.crt": {
+ "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "ECDSA",
+ "size": 256,
+ },
+ },
+ }
+ },
+ {
+ "utcTime": 1702288467.6736515,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-certificate-not-configured",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_SIGNING_CA.crt": {
+ "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "ECDSA",
+ "size": 256,
+ },
+ },
+ }
+ },
+ {
+ "utcTime": 1702288467.6736515,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["SSL certificate 'ARISTA_ROOT_CA.crt', is not configured.\n"],
+ },
+ },
+ {
+ "name": "failure-certificate-expired",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_ROOT_CA.crt": {
+ "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"},
+ "notAfter": 1702533518,
+ "publicKey": {
+ "encryptionAlgorithm": "RSA",
+ "size": 4096,
+ },
+ },
+ }
+ },
+ {
+ "utcTime": 1702622372.2240553,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["SSL certificate 'ARISTA_SIGNING_CA.crt', is not configured.\n", "SSL certificate `ARISTA_ROOT_CA.crt` is expired.\n"],
+ },
+ },
+ {
+ "name": "failure-certificate-about-to-expire",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_ROOT_CA.crt": {
+ "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"},
+ "notAfter": 1704782709,
+ "publicKey": {
+ "encryptionAlgorithm": "RSA",
+ "size": 4096,
+ },
+ },
+ "ARISTA_SIGNING_CA.crt": {
+ "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"},
+ "notAfter": 1702533518,
+ "publicKey": {
+ "encryptionAlgorithm": "ECDSA",
+ "size": 256,
+ },
+ },
+ }
+ },
+ {
+ "utcTime": 1702622372.2240553,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "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."],
+ },
+ },
+ {
+ "name": "failure-wrong-subject-name",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_ROOT_CA.crt": {
+ "subject": {"commonName": "AristaIT-ICA Networks Internal IT Root Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "RSA",
+ "size": 4096,
+ },
+ },
+ "ARISTA_SIGNING_CA.crt": {
+ "subject": {"commonName": "Arista ECDSA Issuing Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "ECDSA",
+ "size": 256,
+ },
+ },
+ }
+ },
+ {
+ "utcTime": 1702288467.6736515,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "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",
+ ],
+ },
+ },
+ {
+ "name": "failure-wrong-encryption-type-and-size",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_ROOT_CA.crt": {
+ "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "ECDSA",
+ "size": 256,
+ },
+ },
+ "ARISTA_SIGNING_CA.crt": {
+ "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"},
+ "notAfter": 2127420899,
+ "publicKey": {
+ "encryptionAlgorithm": "RSA",
+ "size": 4096,
+ },
+ },
+ }
+ },
+ {
+ "utcTime": 1702288467.6736515,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "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",
+ ],
+ },
+ },
+ {
+ "name": "failure-missing-actual-output",
+ "test": VerifyAPISSLCertificate,
+ "eos_data": [
+ {
+ "certificates": {
+ "ARISTA_ROOT_CA.crt": {
+ "subject": {"commonName": "Arista Networks Internal IT Root Cert Authority"},
+ "notAfter": 2127420899,
+ },
+ "ARISTA_SIGNING_CA.crt": {
+ "subject": {"commonName": "AristaIT-ICA ECDSA Issuing Cert Authority"},
+ "notAfter": 2127420899,
+ },
+ }
+ },
+ {
+ "utcTime": 1702288467.6736515,
+ },
+ ],
+ "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,
+ "common_name": "Arista Networks Internal IT Root Cert Authority",
+ "encryption_algorithm": "RSA",
+ "key_size": 4096,
+ },
+ ]
+ },
+ "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",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBannerLogin,
+ "eos_data": [
+ {
+ "loginBanner": "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."
+ }
+ ],
+ "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": "success"},
+ },
+ {
+ "name": "success-multiline",
+ "test": VerifyBannerLogin,
+ "eos_data": [
+ {
+ "loginBanner": "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."
+ }
+ ],
+ "inputs": {
+ "login_banner": """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."""
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-login-banner",
+ "test": VerifyBannerLogin,
+ "eos_data": [
+ {
+ "loginBanner": "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."
+ }
+ ],
+ "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": [
+ "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."
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyBannerMotd,
+ "eos_data": [
+ {
+ "motd": "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."
+ }
+ ],
+ "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": "success"},
+ },
+ {
+ "name": "success-multiline",
+ "test": VerifyBannerMotd,
+ "eos_data": [
+ {
+ "motd": "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."
+ }
+ ],
+ "inputs": {
+ "motd_banner": """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."""
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-motd-banner",
+ "test": VerifyBannerMotd,
+ "eos_data": [
+ {
+ "motd": "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."
+ }
+ ],
+ "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": [
+ "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."
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyIPv4ACL,
+ "eos_data": [
+ {
+ "aclList": [
+ {
+ "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": [
+ {
+ "sequence": [
+ {"text": "permit icmp any any", "sequenceNumber": 10},
+ {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 20},
+ ],
+ }
+ ]
+ },
+ ],
+ "inputs": {
+ "ipv4_access_lists": [
+ {
+ "name": "default-control-plane-acl",
+ "entries": [
+ {"sequence": 10, "action": "permit icmp any any"},
+ {"sequence": 20, "action": "permit ip any any tracked"},
+ {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"},
+ ],
+ },
+ {
+ "name": "LabTest",
+ "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}],
+ },
+ ]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-acl-not-found",
+ "test": VerifyIPv4ACL,
+ "eos_data": [
+ {
+ "aclList": [
+ {
+ "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": []},
+ ],
+ "inputs": {
+ "ipv4_access_lists": [
+ {
+ "name": "default-control-plane-acl",
+ "entries": [
+ {"sequence": 10, "action": "permit icmp any any"},
+ {"sequence": 20, "action": "permit ip any any tracked"},
+ {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"},
+ ],
+ },
+ {
+ "name": "LabTest",
+ "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}],
+ },
+ ]
+ },
+ "expected": {"result": "failure", "messages": ["LabTest: Not found"]},
+ },
+ {
+ "name": "failure-sequence-not-found",
+ "test": VerifyIPv4ACL,
+ "eos_data": [
+ {
+ "aclList": [
+ {
+ "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": [
+ {
+ "sequence": [
+ {"text": "permit icmp any any", "sequenceNumber": 10},
+ {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30},
+ ],
+ }
+ ]
+ },
+ ],
+ "inputs": {
+ "ipv4_access_lists": [
+ {
+ "name": "default-control-plane-acl",
+ "entries": [
+ {"sequence": 10, "action": "permit icmp any any"},
+ {"sequence": 20, "action": "permit ip any any tracked"},
+ {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"},
+ ],
+ },
+ {
+ "name": "LabTest",
+ "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}],
+ },
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["default-control-plane-acl:\nSequence number `30` is not found.\n", "LabTest:\nSequence number `20` is not found.\n"],
+ },
+ },
+ {
+ "name": "failure-action-not-match",
+ "test": VerifyIPv4ACL,
+ "eos_data": [
+ {
+ "aclList": [
+ {
+ "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": [
+ {
+ "sequence": [
+ {"text": "permit icmp any any", "sequenceNumber": 10},
+ {"text": "permit udp any any eq bfd ttl eq 255", "sequenceNumber": 20},
+ ],
+ }
+ ]
+ },
+ ],
+ "inputs": {
+ "ipv4_access_lists": [
+ {
+ "name": "default-control-plane-acl",
+ "entries": [
+ {"sequence": 10, "action": "permit icmp any any"},
+ {"sequence": 20, "action": "permit ip any any tracked"},
+ {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"},
+ ],
+ },
+ {
+ "name": "LabTest",
+ "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}],
+ },
+ ]
+ },
+ "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",
+ ],
+ },
+ },
+ {
+ "name": "failure-all-type",
+ "test": VerifyIPv4ACL,
+ "eos_data": [
+ {
+ "aclList": [
+ {
+ "sequence": [
+ {"text": "permit icmp any any", "sequenceNumber": 10},
+ {"text": "permit ip any any tracked", "sequenceNumber": 40},
+ {"text": "permit tcp any any range 5900 5910", "sequenceNumber": 30},
+ ],
+ }
+ ]
+ },
+ {"aclList": []},
+ ],
+ "inputs": {
+ "ipv4_access_lists": [
+ {
+ "name": "default-control-plane-acl",
+ "entries": [
+ {"sequence": 10, "action": "permit icmp any any"},
+ {"sequence": 20, "action": "permit ip any any tracked"},
+ {"sequence": 30, "action": "permit udp any any eq bfd ttl eq 255"},
+ ],
+ },
+ {
+ "name": "LabTest",
+ "entries": [{"sequence": 10, "action": "permit icmp any any"}, {"sequence": 20, "action": "permit tcp any any range 5900 5910"}],
+ },
+ ]
+ },
+ "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",
+ ],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py
new file mode 100644
index 0000000..dcd1ee2
--- /dev/null
+++ b/tests/units/anta_tests/test_services.py
@@ -0,0 +1,218 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.services.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.services import VerifyDNSLookup, VerifyDNSServers, VerifyErrdisableRecovery, VerifyHostname
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyHostname,
+ "eos_data": [{"hostname": "s1-spine1", "fqdn": "s1-spine1.fun.aristanetworks.com"}],
+ "inputs": {"hostname": "s1-spine1"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-hostname",
+ "test": VerifyHostname,
+ "eos_data": [{"hostname": "s1-spine2", "fqdn": "s1-spine1.fun.aristanetworks.com"}],
+ "inputs": {"hostname": "s1-spine1"},
+ "expected": {
+ "result": "failure",
+ "messages": ["Expected `s1-spine1` as the hostname, but found `s1-spine2` instead."],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyDNSLookup,
+ "eos_data": [
+ {
+ "messages": [
+ "Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\nName:\tarista.com\nAddress: 151.101.130.132\nName:\tarista.com\n"
+ "Address: 151.101.2.132\nName:\tarista.com\nAddress: 151.101.194.132\nName:\tarista.com\nAddress: 151.101.66.132\n\n"
+ ]
+ },
+ {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\nName:\twww.google.com\nAddress: 172.217.12.100\n\n"]},
+ ],
+ "inputs": {"domain_names": ["arista.com", "www.google.com"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyDNSLookup,
+ "eos_data": [
+ {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\n*** Can't find arista.ca: No answer\n\n"]},
+ {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\nName:\twww.google.com\nAddress: 172.217.12.100\n\n"]},
+ {"messages": ["Server:\t\t127.0.0.1\nAddress:\t127.0.0.1#53\n\nNon-authoritative answer:\n*** Can't find google.ca: No answer\n\n"]},
+ ],
+ "inputs": {"domain_names": ["arista.ca", "www.google.com", "google.ca"]},
+ "expected": {"result": "failure", "messages": ["The following domain(s) are not resolved to an IP address: arista.ca, google.ca"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyDNSServers,
+ "eos_data": [
+ {
+ "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}],
+ }
+ ],
+ "inputs": {
+ "dns_servers": [{"server_address": "10.14.0.1", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.11", "vrf": "MGMT", "priority": 1}]
+ },
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-dns-missing",
+ "test": VerifyDNSServers,
+ "eos_data": [
+ {
+ "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}],
+ }
+ ],
+ "inputs": {
+ "dns_servers": [{"server_address": "10.14.0.10", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.21", "vrf": "MGMT", "priority": 1}]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["DNS server `10.14.0.10` is not configured with any VRF.", "DNS server `10.14.0.21` is not configured with any VRF."],
+ },
+ },
+ {
+ "name": "failure-no-dns-found",
+ "test": VerifyDNSServers,
+ "eos_data": [
+ {
+ "nameServerConfigs": [],
+ }
+ ],
+ "inputs": {
+ "dns_servers": [{"server_address": "10.14.0.10", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.21", "vrf": "MGMT", "priority": 1}]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": ["DNS server `10.14.0.10` is not configured with any VRF.", "DNS server `10.14.0.21` is not configured with any VRF."],
+ },
+ },
+ {
+ "name": "failure-incorrect-dns-details",
+ "test": VerifyDNSServers,
+ "eos_data": [
+ {
+ "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "CS", "priority": 1}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}],
+ }
+ ],
+ "inputs": {
+ "dns_servers": [
+ {"server_address": "10.14.0.1", "vrf": "CS", "priority": 0},
+ {"server_address": "10.14.0.11", "vrf": "default", "priority": 0},
+ {"server_address": "10.14.0.110", "vrf": "MGMT", "priority": 0},
+ ]
+ },
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "For DNS server `10.14.0.1`, the expected priority is `0`, but `1` was found instead.",
+ "DNS server `10.14.0.11` is not configured with VRF `default`.",
+ "DNS server `10.14.0.110` is not configured with any VRF.",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyErrdisableRecovery,
+ "eos_data": [
+ """
+ Errdisable Reason Timer Status Timer Interval
+ ------------------------------ ----------------- --------------
+ acl Enabled 300
+ bpduguard Enabled 300
+ arp-inspection Enabled 30
+ """
+ ],
+ "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "bpduguard", "interval": 300}]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-reason-missing",
+ "test": VerifyErrdisableRecovery,
+ "eos_data": [
+ """
+ Errdisable Reason Timer Status Timer Interval
+ ------------------------------ ----------------- --------------
+ acl Enabled 300
+ bpduguard Enabled 300
+ arp-inspection Enabled 30
+ """
+ ],
+ "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}, {"reason": "tapagg", "interval": 30}]},
+ "expected": {
+ "result": "failure",
+ "messages": ["`tapagg`: Not found."],
+ },
+ },
+ {
+ "name": "failure-reason-disabled",
+ "test": VerifyErrdisableRecovery,
+ "eos_data": [
+ """
+ Errdisable Reason Timer Status Timer Interval
+ ------------------------------ ----------------- --------------
+ acl Disabled 300
+ bpduguard Enabled 300
+ arp-inspection Enabled 30
+ """
+ ],
+ "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."],
+ },
+ },
+ {
+ "name": "failure-interval-not-ok",
+ "test": VerifyErrdisableRecovery,
+ "eos_data": [
+ """
+ Errdisable Reason Timer Status Timer Interval
+ ------------------------------ ----------------- --------------
+ acl Enabled 300
+ bpduguard Enabled 300
+ arp-inspection Enabled 30
+ """
+ ],
+ "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."],
+ },
+ },
+ {
+ "name": "failure-all-type",
+ "test": VerifyErrdisableRecovery,
+ "eos_data": [
+ """
+ Errdisable Reason Timer Status Timer Interval
+ ------------------------------ ----------------- --------------
+ acl Disabled 300
+ bpduguard Enabled 300
+ arp-inspection Enabled 30
+ """
+ ],
+ "inputs": {"reasons": [{"reason": "acl", "interval": 30}, {"reason": "arp-inspection", "interval": 300}, {"reason": "tapagg", "interval": 30}]},
+ "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.",
+ ],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py
new file mode 100644
index 0000000..7009689
--- /dev/null
+++ b/tests/units/anta_tests/test_snmp.py
@@ -0,0 +1,128 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.snmp.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpStatus
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifySnmpStatus,
+ "eos_data": [{"vrfs": {"snmpVrfs": ["MGMT", "default"]}, "enabled": True}],
+ "inputs": {"vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-vrf",
+ "test": VerifySnmpStatus,
+ "eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": True}],
+ "inputs": {"vrf": "MGMT"},
+ "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf MGMT"]},
+ },
+ {
+ "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"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpIPv4Acl,
+ "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SNMP", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}],
+ "inputs": {"number": 1, "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-number",
+ "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"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpIPv6Acl,
+ "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SNMP", "configuredVrfs": ["MGMT"], "activeVrfs": ["MGMT"]}]}}],
+ "inputs": {"number": 1, "vrf": "MGMT"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-wrong-number",
+ "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"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpLocation,
+ "eos_data": [
+ {
+ "location": {"location": "New York"},
+ }
+ ],
+ "inputs": {"location": "New York"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-location",
+ "test": VerifySnmpLocation,
+ "eos_data": [
+ {
+ "location": {"location": "Europe"},
+ }
+ ],
+ "inputs": {"location": "New York"},
+ "expected": {
+ "result": "failure",
+ "messages": ["Expected `New York` as the location, but found `Europe` instead."],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifySnmpContact,
+ "eos_data": [
+ {
+ "contact": {"contact": "Jon@example.com"},
+ }
+ ],
+ "inputs": {"contact": "Jon@example.com"},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-contact",
+ "test": VerifySnmpContact,
+ "eos_data": [
+ {
+ "contact": {"contact": "Jon@example.com"},
+ }
+ ],
+ "inputs": {"contact": "Bob@example.com"},
+ "expected": {
+ "result": "failure",
+ "messages": ["Expected `Bob@example.com` as the contact, but found `Jon@example.com` instead."],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_software.py b/tests/units/anta_tests/test_software.py
new file mode 100644
index 0000000..6d39c04
--- /dev/null
+++ b/tests/units/anta_tests/test_software.py
@@ -0,0 +1,101 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Test inputs for anta.tests.hardware"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.software import VerifyEOSExtensions, VerifyEOSVersion, VerifyTerminAttrVersion
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyEOSVersion,
+ "eos_data": [
+ {
+ "modelName": "vEOS-lab",
+ "internalVersion": "4.27.0F-24305004.4270F",
+ "version": "4.27.0F",
+ }
+ ],
+ "inputs": {"versions": ["4.27.0F", "4.28.0F"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyEOSVersion,
+ "eos_data": [
+ {
+ "modelName": "vEOS-lab",
+ "internalVersion": "4.27.0F-24305004.4270F",
+ "version": "4.27.0F",
+ }
+ ],
+ "inputs": {"versions": ["4.27.1F"]},
+ "expected": {"result": "failure", "messages": ["device is running version \"4.27.0F\" not in expected versions: ['4.27.1F']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyTerminAttrVersion,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1107543.52,
+ "modelName": "vEOS-lab",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}],
+ "switchType": "fixedSystem",
+ "packages": {
+ "TerminAttr-core": {"release": "1", "version": "v1.17.0"},
+ },
+ },
+ }
+ ],
+ "inputs": {"versions": ["v1.17.0", "v1.18.1"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyTerminAttrVersion,
+ "eos_data": [
+ {
+ "imageFormatVersion": "1.0",
+ "uptime": 1107543.52,
+ "modelName": "vEOS-lab",
+ "details": {
+ "deviations": [],
+ "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}],
+ "switchType": "fixedSystem",
+ "packages": {
+ "TerminAttr-core": {"release": "1", "version": "v1.17.0"},
+ },
+ },
+ }
+ ],
+ "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']"]},
+ },
+ {
+ "name": "success-no-extensions",
+ "test": VerifyEOSExtensions,
+ "eos_data": [
+ {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]},
+ {"extensions": []},
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyEOSExtensions,
+ "eos_data": [
+ {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]},
+ {"extensions": ["dummy"]},
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Missing EOS extensions: installed [] / configured: ['dummy']"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py
new file mode 100644
index 0000000..26f0b90
--- /dev/null
+++ b/tests/units/anta_tests/test_stp.py
@@ -0,0 +1,328 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.stp.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.stp import VerifySTPBlockedPorts, VerifySTPCounters, VerifySTPForwardingPorts, VerifySTPMode, VerifySTPRootPriority
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifySTPMode,
+ "eos_data": [
+ {"spanningTreeVlanInstances": {"10": {"spanningTreeVlanInstance": {"protocol": "rstp"}}}},
+ {"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "rstp"}}}},
+ ],
+ "inputs": {"mode": "rstp", "vlans": [10, 20]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-instances",
+ "test": VerifySTPMode,
+ "eos_data": [
+ {"spanningTreeVlanInstances": {}},
+ {"spanningTreeVlanInstances": {}},
+ ],
+ "inputs": {"mode": "rstp", "vlans": [10, 20]},
+ "expected": {"result": "failure", "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10, 20]"]},
+ },
+ {
+ "name": "failure-wrong-mode",
+ "test": VerifySTPMode,
+ "eos_data": [
+ {"spanningTreeVlanInstances": {"10": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}},
+ {"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]"]},
+ },
+ {
+ "name": "failure-both",
+ "test": VerifySTPMode,
+ "eos_data": [
+ {"spanningTreeVlanInstances": {}},
+ {"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}},
+ ],
+ "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]"],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifySTPBlockedPorts,
+ "eos_data": [{"spanningTreeInstances": {}}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "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']}"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySTPCounters,
+ "eos_data": [{"interfaces": {"Ethernet10": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 0, "bpduRateLimitCount": 0}}}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifySTPCounters,
+ "eos_data": [
+ {
+ "interfaces": {
+ "Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifySTPForwardingPorts,
+ "eos_data": [
+ {
+ "unmappedVlans": [],
+ "topologies": {"Mst10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}},
+ },
+ {
+ "unmappedVlans": [],
+ "topologies": {"Mst20": {"vlans": [20], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}},
+ },
+ ],
+ "inputs": {"vlans": [10, 20]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-vlan-not-in-topology", # Should it succeed really ? TODO - this output should be impossible
+ "test": VerifySTPForwardingPorts,
+ "eos_data": [
+ {
+ "unmappedVlans": [],
+ "topologies": {"Mst10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}},
+ },
+ {
+ "unmappedVlans": [],
+ "topologies": {"Mst10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "forwarding"}, "MplsTrunk1": {"state": "forwarding"}}}},
+ },
+ ],
+ "inputs": {"vlans": [10, 20]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-instances",
+ "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]"]},
+ },
+ {
+ "name": "failure",
+ "test": VerifySTPForwardingPorts,
+ "eos_data": [
+ {
+ "unmappedVlans": [],
+ "topologies": {"Vl10": {"vlans": [10], "interfaces": {"Ethernet10": {"state": "discarding"}, "MplsTrunk1": {"state": "forwarding"}}}},
+ },
+ {
+ "unmappedVlans": [],
+ "topologies": {"Vl20": {"vlans": [20], "interfaces": {"Ethernet10": {"state": "discarding"}, "MplsTrunk1": {"state": "forwarding"}}}},
+ },
+ ],
+ "inputs": {"vlans": [10, 20]},
+ "expected": {
+ "result": "failure",
+ "messages": ["The following VLAN(s) have interface(s) that are not in a fowarding state: [{'VLAN 10': ['Ethernet10']}, {'VLAN 20': ['Ethernet10']}]"],
+ },
+ },
+ {
+ "name": "success-specific-instances",
+ "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,
+ }
+ },
+ "VL20": {
+ "rootBridge": {
+ "priority": 32768,
+ "systemIdExtension": 20,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ },
+ "VL30": {
+ "rootBridge": {
+ "priority": 32768,
+ "systemIdExtension": 30,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {"priority": 32768, "instances": [10, 20]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-all-instances",
+ "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,
+ }
+ },
+ "VL20": {
+ "rootBridge": {
+ "priority": 32768,
+ "systemIdExtension": 20,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ },
+ "VL30": {
+ "rootBridge": {
+ "priority": 32768,
+ "systemIdExtension": 30,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {"priority": 32768},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-MST",
+ "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, "instances": [0]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-instances",
+ "test": VerifySTPRootPriority,
+ "eos_data": [
+ {
+ "instances": {
+ "WRONG0": {
+ "rootBridge": {
+ "priority": 16384,
+ "systemIdExtension": 0,
+ "macAddress": "02:1c:73:8b:93:ac",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ }
+ }
+ }
+ ],
+ "inputs": {"priority": 32768, "instances": [0]},
+ "expected": {"result": "failure", "messages": ["Unsupported STP instance type: WRONG0"]},
+ },
+ {
+ "name": "failure-wrong-instance-type",
+ "test": VerifySTPRootPriority,
+ "eos_data": [{"instances": {}}],
+ "inputs": {"priority": 32768, "instances": [10, 20]},
+ "expected": {"result": "failure", "messages": ["No STP instances configured"]},
+ },
+ {
+ "name": "failure-wrong-priority",
+ "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,
+ }
+ },
+ "VL20": {
+ "rootBridge": {
+ "priority": 8196,
+ "systemIdExtension": 20,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ },
+ "VL30": {
+ "rootBridge": {
+ "priority": 8196,
+ "systemIdExtension": 30,
+ "macAddress": "00:1c:73:27:95:a2",
+ "helloTime": 2.0,
+ "maxAge": 20,
+ "forwardDelay": 15,
+ }
+ },
+ }
+ }
+ ],
+ "inputs": {"priority": 32768, "instances": [10, 20, 30]},
+ "expected": {"result": "failure", "messages": ["The following instance(s) have the wrong STP root priority configured: ['VL20', 'VL30']"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py
new file mode 100644
index 0000000..62260fa
--- /dev/null
+++ b/tests/units/anta_tests/test_system.py
@@ -0,0 +1,283 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Test inputs for anta.tests.system"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.system import (
+ VerifyAgentLogs,
+ VerifyCoredump,
+ VerifyCPUUtilization,
+ VerifyFileSystemUtilization,
+ VerifyMemoryUtilization,
+ VerifyNTP,
+ VerifyReloadCause,
+ VerifyUptime,
+)
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyUptime,
+ "eos_data": [{"upTime": 1186689.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}],
+ "inputs": {"minimum": 666},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "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"]},
+ },
+ {
+ "name": "success-no-reload",
+ "test": VerifyReloadCause,
+ "eos_data": [{"kernelCrashData": [], "resetCauses": [], "full": False}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-valid-cause",
+ "test": VerifyReloadCause,
+ "eos_data": [
+ {
+ "resetCauses": [
+ {"recommendedAction": "No action necessary.", "description": "Reload requested by the user.", "timestamp": 1683186892.0, "debugInfoIsDir": False}
+ ],
+ "full": False,
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyReloadCause,
+ # The failure cause is made up
+ "eos_data": [
+ {
+ "resetCauses": [
+ {"recommendedAction": "No action necessary.", "description": "Reload after crash.", "timestamp": 1683186892.0, "debugInfoIsDir": False}
+ ],
+ "full": False,
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Reload cause is: 'Reload after crash.'"]},
+ },
+ {
+ "name": "error",
+ "test": VerifyReloadCause,
+ "eos_data": [{}],
+ "inputs": None,
+ "expected": {"result": "error", "messages": ["No reload causes available"]},
+ },
+ {
+ "name": "success-without-minidump",
+ "test": VerifyCoredump,
+ "eos_data": [{"mode": "compressedDeferred", "coreFiles": []}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "success-with-minidump",
+ "test": VerifyCoredump,
+ "eos_data": [{"mode": "compressedDeferred", "coreFiles": ["minidump"]}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-without-minidump",
+ "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']"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyAgentLogs,
+ "eos_data": [""],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyAgentLogs,
+ "eos_data": [
+ """===> /var/log/agents/Test-666 Thu May 4 09:57:02 2023 <===
+CLI Exception: Exception
+CLI Exception: Backtrace
+===> /var/log/agents/Aaa-855 Fri Jul 7 15:07:00 2023 <===
+===== Output from /usr/bin/Aaa [] (PID=855) started Jul 7 15:06:11.606414 ===
+EntityManager::doBackoff waiting for remote sysdb version ....ok
+
+===> /var/log/agents/Acl-830 Fri Jul 7 15:07:00 2023 <===
+===== Output from /usr/bin/Acl [] (PID=830) started Jul 7 15:06:10.871700 ===
+EntityManager::doBackoff waiting for remote sysdb version ...................ok
+"""
+ ],
+ "inputs": None,
+ "expected": {
+ "result": "failure",
+ "messages": [
+ "Device has reported agent crashes:\n"
+ " * /var/log/agents/Test-666 Thu May 4 09:57:02 2023\n"
+ " * /var/log/agents/Aaa-855 Fri Jul 7 15:07:00 2023\n"
+ " * /var/log/agents/Acl-830 Fri Jul 7 15:07:00 2023",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyCPUUtilization,
+ "eos_data": [
+ {
+ "cpuInfo": {"%Cpu(s)": {"idle": 88.2, "stolen": 0.0, "user": 5.9, "swIrq": 0.0, "ioWait": 0.0, "system": 0.0, "hwIrq": 5.9, "nice": 0.0}},
+ "processes": {
+ "1": {
+ "userName": "root",
+ "status": "S",
+ "memPct": 0.3,
+ "niceValue": 0,
+ "cpuPct": 0.0,
+ "cpuPctType": "{:.1f}",
+ "cmd": "systemd",
+ "residentMem": "5096",
+ "priority": "20",
+ "activeTime": 360,
+ "virtMem": "6644",
+ "sharedMem": "3996",
+ }
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyCPUUtilization,
+ "eos_data": [
+ {
+ "cpuInfo": {"%Cpu(s)": {"idle": 24.8, "stolen": 0.0, "user": 5.9, "swIrq": 0.0, "ioWait": 0.0, "system": 0.0, "hwIrq": 5.9, "nice": 0.0}},
+ "processes": {
+ "1": {
+ "userName": "root",
+ "status": "S",
+ "memPct": 0.3,
+ "niceValue": 0,
+ "cpuPct": 0.0,
+ "cpuPctType": "{:.1f}",
+ "cmd": "systemd",
+ "residentMem": "5096",
+ "priority": "20",
+ "activeTime": 360,
+ "virtMem": "6644",
+ "sharedMem": "3996",
+ }
+ },
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization: 75.2%"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyMemoryUtilization,
+ "eos_data": [
+ {
+ "uptime": 1994.67,
+ "modelName": "vEOS-lab",
+ "internalVersion": "4.27.3F-26379303.4273F",
+ "memTotal": 2004568,
+ "memFree": 879004,
+ "version": "4.27.3F",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyMemoryUtilization,
+ "eos_data": [
+ {
+ "uptime": 1994.67,
+ "modelName": "vEOS-lab",
+ "internalVersion": "4.27.3F-26379303.4273F",
+ "memTotal": 2004568,
+ "memFree": 89004,
+ "version": "4.27.3F",
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Device has reported a high memory usage: 95.56%"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyFileSystemUtilization,
+ "eos_data": [
+ """Filesystem Size Used Avail Use% Mounted on
+/dev/sda2 3.9G 988M 2.9G 26% /mnt/flash
+none 294M 78M 217M 27% /
+none 294M 78M 217M 27% /.overlay
+/dev/loop0 461M 461M 0 100% /rootfs-i386
+"""
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyFileSystemUtilization,
+ "eos_data": [
+ """Filesystem Size Used Avail Use% Mounted on
+/dev/sda2 3.9G 988M 2.9G 84% /mnt/flash
+none 294M 78M 217M 27% /
+none 294M 78M 217M 84% /.overlay
+/dev/loop0 461M 461M 0 100% /rootfs-i386
+"""
+ ],
+ "inputs": None,
+ "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%",
+ ],
+ },
+ },
+ {
+ "name": "success",
+ "test": VerifyNTP,
+ "eos_data": [
+ """synchronised
+poll interval unknown
+"""
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyNTP,
+ "eos_data": [
+ """unsynchronised
+poll interval unknown
+"""
+ ],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["The device is not synchronized with the configured NTP server(s): 'unsynchronised'"]},
+ },
+]
diff --git a/tests/units/anta_tests/test_vlan.py b/tests/units/anta_tests/test_vlan.py
new file mode 100644
index 0000000..93398f6
--- /dev/null
+++ b/tests/units/anta_tests/test_vlan.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.vlan.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.vlan import VerifyVlanInternalPolicy
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyVlanInternalPolicy,
+ "eos_data": [{"policy": "ascending", "startVlanId": 1006, "endVlanId": 4094}],
+ "inputs": {"policy": "ascending", "start_vlan_id": 1006, "end_vlan_id": 4094},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-incorrect-policy",
+ "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": [
+ "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."
+ ],
+ },
+ },
+]
diff --git a/tests/units/anta_tests/test_vxlan.py b/tests/units/anta_tests/test_vxlan.py
new file mode 100644
index 0000000..2a9a875
--- /dev/null
+++ b/tests/units/anta_tests/test_vxlan.py
@@ -0,0 +1,365 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tests.vxlan.py
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from anta.tests.vxlan import VerifyVxlan1ConnSettings, VerifyVxlan1Interface, VerifyVxlanConfigSanity, VerifyVxlanVniBinding, VerifyVxlanVtep
+from tests.lib.anta import test # noqa: F401; pylint: disable=W0611
+
+DATA: list[dict[str, Any]] = [
+ {
+ "name": "success",
+ "test": VerifyVxlan1Interface,
+ "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "up", "interfaceStatus": "up"}}}],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped",
+ "test": VerifyVxlan1Interface,
+ "eos_data": [{"interfaceDescriptions": {"Loopback0": {"lineProtocolStatus": "up", "interfaceStatus": "up"}}}],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]},
+ },
+ {
+ "name": "failure",
+ "test": VerifyVxlan1Interface,
+ "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "up"}}}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/up"]},
+ },
+ {
+ "name": "failure",
+ "test": VerifyVxlan1Interface,
+ "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "up", "interfaceStatus": "down"}}}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Vxlan1 interface is up/down"]},
+ },
+ {
+ "name": "failure",
+ "test": VerifyVxlan1Interface,
+ "eos_data": [{"interfaceDescriptions": {"Vxlan1": {"lineProtocolStatus": "down", "interfaceStatus": "down"}}}],
+ "inputs": None,
+ "expected": {"result": "failure", "messages": ["Vxlan1 interface is down/down"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyVxlanConfigSanity,
+ "eos_data": [
+ {
+ "categories": {
+ "localVtep": {
+ "description": "Local VTEP Configuration Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [
+ {"name": "Loopback IP Address", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "VLAN-VNI Map", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "Flood List", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"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": ""},
+ ],
+ },
+ "remoteVtep": {
+ "description": "Remote VTEP Configuration Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [{"name": "Remote VTEP", "checkPass": True, "hasWarning": False, "detail": ""}],
+ },
+ "pd": {
+ "description": "Platform Dependent Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [
+ {"name": "VXLAN Bridging", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "VXLAN Routing", "checkPass": True, "hasWarning": False, "detail": "VXLAN Routing not enabled"},
+ ],
+ },
+ "cvx": {
+ "description": "CVX Configuration Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [{"name": "CVX Server", "checkPass": True, "hasWarning": False, "detail": "Not in controller client mode"}],
+ },
+ "mlag": {
+ "description": "MLAG Configuration Check",
+ "allCheckPass": True,
+ "detail": "Run 'show mlag config-sanity' to verify MLAG config",
+ "hasWarning": False,
+ "items": [
+ {"name": "Peer VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "MLAG VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "Virtual VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "Peer VLAN-VNI", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "MLAG Inactive State", "checkPass": True, "hasWarning": False, "detail": ""},
+ ],
+ },
+ },
+ "warnings": [],
+ }
+ ],
+ "inputs": None,
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure",
+ "test": VerifyVxlanConfigSanity,
+ "eos_data": [
+ {
+ "categories": {
+ "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": ""},
+ ],
+ },
+ "remoteVtep": {
+ "description": "Remote VTEP Configuration Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [{"name": "Remote VTEP", "checkPass": True, "hasWarning": False, "detail": ""}],
+ },
+ "pd": {
+ "description": "Platform Dependent Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [
+ {"name": "VXLAN Bridging", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "VXLAN Routing", "checkPass": True, "hasWarning": False, "detail": "VXLAN Routing not enabled"},
+ ],
+ },
+ "cvx": {
+ "description": "CVX Configuration Check",
+ "allCheckPass": True,
+ "detail": "",
+ "hasWarning": False,
+ "items": [{"name": "CVX Server", "checkPass": True, "hasWarning": False, "detail": "Not in controller client mode"}],
+ },
+ "mlag": {
+ "description": "MLAG Configuration Check",
+ "allCheckPass": True,
+ "detail": "Run 'show mlag config-sanity' to verify MLAG config",
+ "hasWarning": False,
+ "items": [
+ {"name": "Peer VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "MLAG VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "Virtual VTEP IP", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "Peer VLAN-VNI", "checkPass": True, "hasWarning": False, "detail": ""},
+ {"name": "MLAG Inactive State", "checkPass": True, "hasWarning": False, "detail": ""},
+ ],
+ },
+ },
+ "warnings": ["Your configuration contains warnings. This does not mean misconfigurations. But you may wish to re-check your configurations."],
+ }
+ ],
+ "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': ''}]}}"
+ ],
+ },
+ },
+ {
+ "name": "skipped",
+ "test": VerifyVxlanConfigSanity,
+ "eos_data": [{"categories": {}}],
+ "inputs": None,
+ "expected": {"result": "skipped", "messages": ["VXLAN is not configured"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyVxlanVniBinding,
+ "eos_data": [
+ {
+ "vxlanIntfs": {
+ "Vxlan1": {
+ "vniBindings": {
+ "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}
+ },
+ "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}},
+ }
+ }
+ }
+ ],
+ "inputs": {"bindings": {10020: 20, 500: 1199}},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-no-binding",
+ "test": VerifyVxlanVniBinding,
+ "eos_data": [
+ {
+ "vxlanIntfs": {
+ "Vxlan1": {
+ "vniBindings": {
+ "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}
+ },
+ "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}},
+ }
+ }
+ }
+ ],
+ "inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}},
+ "expected": {"result": "failure", "messages": ["The following VNI(s) have no binding: ['10010']"]},
+ },
+ {
+ "name": "failure-wrong-binding",
+ "test": VerifyVxlanVniBinding,
+ "eos_data": [
+ {
+ "vxlanIntfs": {
+ "Vxlan1": {
+ "vniBindings": {
+ "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}
+ },
+ "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}},
+ }
+ }
+ }
+ ],
+ "inputs": {"bindings": {10020: 20, 500: 1199}},
+ "expected": {"result": "failure", "messages": ["The following VNI(s) have the wrong VLAN binding: [{'10020': 30}]"]},
+ },
+ {
+ "name": "failure-no-and-wrong-binding",
+ "test": VerifyVxlanVniBinding,
+ "eos_data": [
+ {
+ "vxlanIntfs": {
+ "Vxlan1": {
+ "vniBindings": {
+ "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}
+ },
+ "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}},
+ }
+ }
+ }
+ ],
+ "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}]"],
+ },
+ },
+ {
+ "name": "skipped",
+ "test": VerifyVxlanVniBinding,
+ "eos_data": [{"vxlanIntfs": {}}],
+ "inputs": {"bindings": {10020: 20, 500: 1199}},
+ "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]},
+ },
+ {
+ "name": "success",
+ "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"]},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "failure-missing-vtep",
+ "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']"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "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']"]},
+ },
+ {
+ "name": "failure-missmatch",
+ "test": VerifyVxlanVtep,
+ "eos_data": [{"vteps": {}, "interfaces": {"Vxlan1": {"vteps": ["10.1.1.6", "10.1.1.7", "10.1.1.8"]}}}],
+ "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']",
+ "Unexpected VTEP peer(s) on Vxlan1 interface: ['10.1.1.7', '10.1.1.8']",
+ ],
+ },
+ },
+ {
+ "name": "skipped",
+ "test": VerifyVxlanVtep,
+ "eos_data": [{"vteps": {}, "interfaces": {}}],
+ "inputs": {"vteps": ["10.1.1.5", "10.1.1.6", "10.1.1.7"]},
+ "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured"]},
+ },
+ {
+ "name": "success",
+ "test": VerifyVxlan1ConnSettings,
+ "eos_data": [{"interfaces": {"Vxlan1": {"srcIpIntf": "Loopback1", "udpPort": 4789}}}],
+ "inputs": {"source_interface": "Loopback1", "udp_port": 4789},
+ "expected": {"result": "success"},
+ },
+ {
+ "name": "skipped",
+ "test": VerifyVxlan1ConnSettings,
+ "eos_data": [{"interfaces": {}}],
+ "inputs": {"source_interface": "Loopback1", "udp_port": 4789},
+ "expected": {"result": "skipped", "messages": ["Vxlan1 interface is not configured."]},
+ },
+ {
+ "name": "failure-wrong-interface",
+ "test": VerifyVxlan1ConnSettings,
+ "eos_data": [{"interfaces": {"Vxlan1": {"srcIpIntf": "Loopback10", "udpPort": 4789}}}],
+ "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."],
+ },
+ },
+ {
+ "name": "failure-wrong-port",
+ "test": VerifyVxlan1ConnSettings,
+ "eos_data": [{"interfaces": {"Vxlan1": {"srcIpIntf": "Loopback10", "udpPort": 4789}}}],
+ "inputs": {"source_interface": "Lo1", "udp_port": 4780},
+ "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.",
+ ],
+ },
+ },
+]
diff --git a/tests/units/cli/__init__.py b/tests/units/cli/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/cli/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/cli/check/__init__.py b/tests/units/cli/check/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/cli/check/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/cli/check/test__init__.py b/tests/units/cli/check/test__init__.py
new file mode 100644
index 0000000..a3a770b
--- /dev/null
+++ b/tests/units/cli/check/test__init__.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.check
+"""
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+
+def test_anta_check(click_runner: CliRunner) -> None:
+ """
+ Test anta check
+ """
+ result = click_runner.invoke(anta, ["check"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta check" in result.output
+
+
+def test_anta_check_help(click_runner: CliRunner) -> None:
+ """
+ Test anta check --help
+ """
+ result = click_runner.invoke(anta, ["check", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta check" in result.output
diff --git a/tests/units/cli/check/test_commands.py b/tests/units/cli/check/test_commands.py
new file mode 100644
index 0000000..746b315
--- /dev/null
+++ b/tests/units/cli/check/test_commands.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.check.commands
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+if TYPE_CHECKING:
+ from click.testing import CliRunner
+
+DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data"
+
+
+@pytest.mark.parametrize(
+ "catalog_path, expected_exit, expected_output",
+ [
+ pytest.param("ghost_catalog.yml", ExitCode.USAGE_ERROR, "Error: Invalid value for '--catalog'", id="catalog does not exist"),
+ pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"),
+ pytest.param("test_catalog.yml", ExitCode.OK, "Catalog is valid", id="catalog valid"),
+ ],
+)
+def test_catalog(click_runner: CliRunner, catalog_path: Path, expected_exit: int, expected_output: str) -> None:
+ """
+ Test `anta check catalog -c catalog
+ """
+ result = click_runner.invoke(anta, ["check", "catalog", "-c", str(DATA_DIR / catalog_path)])
+ assert result.exit_code == expected_exit
+ assert expected_output in result.output
diff --git a/tests/units/cli/debug/__init__.py b/tests/units/cli/debug/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/cli/debug/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/cli/debug/test__init__.py b/tests/units/cli/debug/test__init__.py
new file mode 100644
index 0000000..062182d
--- /dev/null
+++ b/tests/units/cli/debug/test__init__.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.debug
+"""
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+
+def test_anta_debug(click_runner: CliRunner) -> None:
+ """
+ Test anta debug
+ """
+ result = click_runner.invoke(anta, ["debug"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta debug" in result.output
+
+
+def test_anta_debug_help(click_runner: CliRunner) -> None:
+ """
+ Test anta debug --help
+ """
+ result = click_runner.invoke(anta, ["debug", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta debug" in result.output
diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py
new file mode 100644
index 0000000..6d9ac29
--- /dev/null
+++ b/tests/units/cli/debug/test_commands.py
@@ -0,0 +1,60 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.debug.commands
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Literal
+
+import pytest
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+if TYPE_CHECKING:
+ from click.testing import CliRunner
+
+
+@pytest.mark.parametrize(
+ "command, ofmt, version, revision, device, failed",
+ [
+ pytest.param("show version", "json", None, None, "dummy", False, id="json command"),
+ pytest.param("show version", "text", None, None, "dummy", False, id="text command"),
+ pytest.param("show version", None, "latest", None, "dummy", False, id="version-latest"),
+ pytest.param("show version", None, "1", None, "dummy", False, id="version"),
+ pytest.param("show version", None, None, 3, "dummy", False, id="revision"),
+ pytest.param("undefined", None, None, None, "dummy", True, id="command fails"),
+ ],
+)
+def test_run_cmd(
+ click_runner: CliRunner, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"] | None, revision: int | None, device: str, failed: bool
+) -> None:
+ """
+ Test `anta debug run-cmd`
+ """
+ # pylint: disable=too-many-arguments
+ cli_args = ["-l", "debug", "debug", "run-cmd", "--command", command, "--device", device]
+
+ # ofmt
+ if ofmt is not None:
+ cli_args.extend(["--ofmt", ofmt])
+
+ # version
+ if version is not None:
+ cli_args.extend(["--version", version])
+
+ # revision
+ if revision is not None:
+ cli_args.extend(["--revision", str(revision)])
+
+ result = click_runner.invoke(anta, cli_args)
+ if failed:
+ assert result.exit_code == ExitCode.USAGE_ERROR
+ else:
+ assert result.exit_code == ExitCode.OK
+ if revision is not None:
+ assert f"revision={revision}" in result.output
+ if version is not None:
+ assert (f"version='{version}'" if version == "latest" else f"version={version}") in result.output
diff --git a/tests/units/cli/exec/__init__.py b/tests/units/cli/exec/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/cli/exec/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/cli/exec/test__init__.py b/tests/units/cli/exec/test__init__.py
new file mode 100644
index 0000000..f8ad365
--- /dev/null
+++ b/tests/units/cli/exec/test__init__.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.exec
+"""
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+
+def test_anta_exec(click_runner: CliRunner) -> None:
+ """
+ Test anta exec
+ """
+ result = click_runner.invoke(anta, ["exec"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta exec" in result.output
+
+
+def test_anta_exec_help(click_runner: CliRunner) -> None:
+ """
+ Test anta exec --help
+ """
+ result = click_runner.invoke(anta, ["exec", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta exec" in result.output
diff --git a/tests/units/cli/exec/test_commands.py b/tests/units/cli/exec/test_commands.py
new file mode 100644
index 0000000..f96d7f6
--- /dev/null
+++ b/tests/units/cli/exec/test_commands.py
@@ -0,0 +1,125 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.exec.commands
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from anta.cli import anta
+from anta.cli.exec.commands import clear_counters, collect_tech_support, snapshot
+from anta.cli.utils import ExitCode
+
+if TYPE_CHECKING:
+ from click.testing import CliRunner
+
+
+def test_clear_counters_help(click_runner: CliRunner) -> None:
+ """
+ Test `anta exec clear-counters --help`
+ """
+ result = click_runner.invoke(clear_counters, ["--help"])
+ assert result.exit_code == 0
+ assert "Usage" in result.output
+
+
+def test_snapshot_help(click_runner: CliRunner) -> None:
+ """
+ Test `anta exec snapshot --help`
+ """
+ result = click_runner.invoke(snapshot, ["--help"])
+ assert result.exit_code == 0
+ assert "Usage" in result.output
+
+
+def test_collect_tech_support_help(click_runner: CliRunner) -> None:
+ """
+ Test `anta exec collect-tech-support --help`
+ """
+ result = click_runner.invoke(collect_tech_support, ["--help"])
+ assert result.exit_code == 0
+ assert "Usage" in result.output
+
+
+@pytest.mark.parametrize(
+ "tags",
+ [
+ pytest.param(None, id="no tags"),
+ pytest.param("leaf,spine", id="with tags"),
+ ],
+)
+def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None:
+ """
+ Test `anta exec clear-counters`
+ """
+ cli_args = ["exec", "clear-counters"]
+ if tags is not None:
+ cli_args.extend(["--tags", tags])
+ result = click_runner.invoke(anta, cli_args)
+ assert result.exit_code == ExitCode.OK
+
+
+COMMAND_LIST_PATH_FILE = Path(__file__).parent.parent.parent.parent / "data" / "test_snapshot_commands.yml"
+
+
+@pytest.mark.parametrize(
+ "commands_path, tags",
+ [
+ pytest.param(None, None, id="missing command list"),
+ pytest.param(Path("/I/do/not/exist"), None, id="wrong path for command_list"),
+ pytest.param(COMMAND_LIST_PATH_FILE, None, id="command-list only"),
+ pytest.param(COMMAND_LIST_PATH_FILE, "leaf,spine", id="with tags"),
+ ],
+)
+def test_snapshot(tmp_path: Path, click_runner: CliRunner, commands_path: Path | None, tags: str | None) -> None:
+ """
+ Test `anta exec snapshot`
+ """
+ cli_args = ["exec", "snapshot", "--output", str(tmp_path)]
+ # Need to mock datetetime
+ if commands_path is not None:
+ cli_args.extend(["--commands-list", str(commands_path)])
+ if tags is not None:
+ cli_args.extend(["--tags", tags])
+ result = click_runner.invoke(anta, cli_args)
+ # Failure scenarios
+ if commands_path is None:
+ assert result.exit_code == ExitCode.USAGE_ERROR
+ return
+ if not Path.exists(Path(commands_path)):
+ assert result.exit_code == ExitCode.USAGE_ERROR
+ return
+ assert result.exit_code == ExitCode.OK
+
+
+@pytest.mark.parametrize(
+ "output, latest, configure, tags",
+ [
+ pytest.param(None, None, False, None, id="no params"),
+ pytest.param("/tmp/dummy", None, False, None, id="with output"),
+ pytest.param(None, 1, False, None, id="only last show tech"),
+ pytest.param(None, None, True, None, id="configure"),
+ pytest.param(None, None, False, "leaf,spine", id="with tags"),
+ ],
+)
+def test_collect_tech_support(click_runner: CliRunner, output: str | None, latest: str | None, configure: bool | None, tags: str | None) -> None:
+ """
+ Test `anta exec collect-tech-support`
+ """
+ cli_args = ["exec", "collect-tech-support"]
+ if output is not None:
+ cli_args.extend(["--output", output])
+ if latest is not None:
+ cli_args.extend(["--latest", latest])
+ if configure is True:
+ cli_args.extend(["--configure"])
+ if tags is not None:
+ cli_args.extend(["--tags", tags])
+ result = click_runner.invoke(anta, cli_args)
+ assert result.exit_code == ExitCode.OK
diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py
new file mode 100644
index 0000000..6df1c86
--- /dev/null
+++ b/tests/units/cli/exec/test_utils.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.exec.utils
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+from unittest.mock import call, patch
+
+import pytest
+
+from anta.cli.exec.utils import clear_counters_utils # , collect_commands, collect_scheduled_show_tech
+from anta.device import AntaDevice
+from anta.inventory import AntaInventory
+from anta.models import AntaCommand
+
+if TYPE_CHECKING:
+ from pytest import LogCaptureFixture
+
+
+# TODO complete test cases
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "inventory_state, per_device_command_output, tags",
+ [
+ pytest.param(
+ {"dummy": {"is_online": False}, "dummy2": {"is_online": False}, "dummy3": {"is_online": False}},
+ {},
+ None,
+ id="no_connected_device",
+ ),
+ pytest.param(
+ {"dummy": {"is_online": True, "hw_model": "cEOSLab"}, "dummy2": {"is_online": True, "hw_model": "vEOS-lab"}, "dummy3": {"is_online": False}},
+ {},
+ None,
+ id="cEOSLab and vEOS-lab devices",
+ ),
+ pytest.param(
+ {"dummy": {"is_online": True}, "dummy2": {"is_online": True}, "dummy3": {"is_online": False}},
+ {"dummy": None}, # None means the command failed to collect
+ None,
+ id="device with error",
+ ),
+ pytest.param(
+ {"dummy": {"is_online": True}, "dummy2": {"is_online": True}, "dummy3": {"is_online": True}},
+ {},
+ ["spine"],
+ id="tags",
+ ),
+ ],
+)
+async def test_clear_counters_utils(
+ caplog: LogCaptureFixture,
+ test_inventory: AntaInventory,
+ inventory_state: dict[str, Any],
+ per_device_command_output: dict[str, Any],
+ tags: list[str] | None,
+) -> None:
+ """
+ Test anta.cli.exec.utils.clear_counters_utils
+ """
+
+ async def mock_connect_inventory() -> None:
+ """
+ mocking connect_inventory coroutine
+ """
+ for name, device in test_inventory.items():
+ device.is_online = inventory_state[name].get("is_online", True)
+ device.established = inventory_state[name].get("established", device.is_online)
+ device.hw_model = inventory_state[name].get("hw_model", "dummy")
+
+ async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None:
+ """
+ mocking collect coroutine
+ """
+ command.output = per_device_command_output.get(self.name, "")
+
+ # Need to patch the child device class
+ with patch("anta.device.AsyncEOSDevice.collect", side_effect=dummy_collect, autospec=True) as mocked_collect, patch(
+ "anta.inventory.AntaInventory.connect_inventory",
+ side_effect=mock_connect_inventory,
+ ) as mocked_connect_inventory:
+ print(mocked_collect)
+ mocked_collect.side_effect = dummy_collect
+ await clear_counters_utils(test_inventory, tags=tags)
+
+ mocked_connect_inventory.assert_awaited_once()
+ devices_established = list(test_inventory.get_inventory(established_only=True, tags=tags).values())
+ if devices_established:
+ # Building the list of calls
+ calls = []
+ for device in devices_established:
+ calls.append(
+ call(
+ device,
+ **{
+ "command": AntaCommand(
+ command="clear counters",
+ version="latest",
+ revision=None,
+ ofmt="json",
+ output=per_device_command_output.get(device.name, ""),
+ errors=[],
+ )
+ },
+ )
+ )
+ if device.hw_model not in ["cEOSLab", "vEOS-lab"]:
+ calls.append(
+ call(
+ device,
+ **{
+ "command": AntaCommand(
+ command="clear hardware counter drop",
+ version="latest",
+ revision=None,
+ ofmt="json",
+ output=per_device_command_output.get(device.name, ""),
+ )
+ },
+ )
+ )
+ mocked_collect.assert_has_awaits(calls)
+ # Check error
+ for key, value in per_device_command_output.items():
+ if value is None:
+ # means some command failed to collect
+ assert "ERROR" in caplog.text
+ assert f"Could not clear counters on device {key}: []" in caplog.text
+ else:
+ mocked_collect.assert_not_awaited()
diff --git a/tests/units/cli/get/__init__.py b/tests/units/cli/get/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/cli/get/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/cli/get/test__init__.py b/tests/units/cli/get/test__init__.py
new file mode 100644
index 0000000..b18ef88
--- /dev/null
+++ b/tests/units/cli/get/test__init__.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.get
+"""
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+
+def test_anta_get(click_runner: CliRunner) -> None:
+ """
+ Test anta get
+ """
+ result = click_runner.invoke(anta, ["get"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta get" in result.output
+
+
+def test_anta_get_help(click_runner: CliRunner) -> None:
+ """
+ Test anta get --help
+ """
+ result = click_runner.invoke(anta, ["get", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta get" in result.output
diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py
new file mode 100644
index 0000000..aa6dc4f
--- /dev/null
+++ b/tests/units/cli/get/test_commands.py
@@ -0,0 +1,204 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.get.commands
+"""
+from __future__ import annotations
+
+import filecmp
+from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import ANY, patch
+
+import pytest
+from cvprac.cvp_client import CvpClient
+from cvprac.cvp_client_errors import CvpApiError
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+if TYPE_CHECKING:
+ from click.testing import CliRunner
+
+DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data"
+
+
+@pytest.mark.parametrize(
+ "cvp_container, cvp_connect_failure",
+ [
+ pytest.param(None, False, id="all devices"),
+ pytest.param("custom_container", False, id="custom container"),
+ pytest.param(None, True, id="cvp connect failure"),
+ ],
+)
+def test_from_cvp(
+ tmp_path: Path,
+ click_runner: CliRunner,
+ cvp_container: str | None,
+ cvp_connect_failure: bool,
+) -> None:
+ """
+ Test `anta get from-cvp`
+
+ This test verifies that username and password are NOT mandatory to run this command
+ """
+ output: Path = tmp_path / "output.yml"
+ cli_args = ["get", "from-cvp", "--output", str(output), "--host", "42.42.42.42", "--username", "anta", "--password", "anta"]
+
+ if cvp_container is not None:
+ cli_args.extend(["--container", cvp_container])
+
+ def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None:
+ # pylint: disable=unused-argument
+ if cvp_connect_failure:
+ raise CvpApiError(msg="mocked CvpApiError")
+
+ # always get a token
+ with patch("anta.cli.get.commands.get_cv_token", return_value="dummy_token"), patch(
+ "cvprac.cvp_client.CvpClient.connect", autospec=True, side_effect=mock_cvp_connect
+ ) as mocked_cvp_connect, patch("cvprac.cvp_client.CvpApi.get_inventory", autospec=True, return_value=[]) as mocked_get_inventory, patch(
+ "cvprac.cvp_client.CvpApi.get_devices_in_container", autospec=True, return_value=[]
+ ) as mocked_get_devices_in_container:
+ result = click_runner.invoke(anta, cli_args)
+
+ if not cvp_connect_failure:
+ assert output.exists()
+
+ mocked_cvp_connect.assert_called_once()
+ if not cvp_connect_failure:
+ assert "Connected to CloudVision" in result.output
+ if cvp_container is not None:
+ mocked_get_devices_in_container.assert_called_once_with(ANY, cvp_container)
+ else:
+ mocked_get_inventory.assert_called_once()
+ assert result.exit_code == ExitCode.OK
+ else:
+ assert "Error connecting to CloudVision" in result.output
+ assert result.exit_code == ExitCode.USAGE_ERROR
+
+
+@pytest.mark.parametrize(
+ "ansible_inventory, ansible_group, expected_exit, expected_log",
+ [
+ pytest.param("ansible_inventory.yml", None, ExitCode.OK, None, id="no group"),
+ pytest.param("ansible_inventory.yml", "ATD_LEAFS", ExitCode.OK, None, id="group found"),
+ pytest.param("ansible_inventory.yml", "DUMMY", ExitCode.USAGE_ERROR, "Group DUMMY not found in Ansible inventory", id="group not found"),
+ pytest.param("empty_ansible_inventory.yml", None, ExitCode.USAGE_ERROR, "is empty", id="empty inventory"),
+ ],
+)
+def test_from_ansible(
+ tmp_path: Path,
+ click_runner: CliRunner,
+ ansible_inventory: Path,
+ ansible_group: str | None,
+ expected_exit: int,
+ expected_log: str | None,
+) -> None:
+ """
+ Test `anta get from-ansible`
+
+ This test verifies:
+ * the parsing of an ansible-inventory
+ * the ansible_group functionaliy
+
+ The output path is ALWAYS set to a non existing file.
+ """
+ output: Path = tmp_path / "output.yml"
+ ansible_inventory_path = DATA_DIR / ansible_inventory
+ # Init cli_args
+ cli_args = ["get", "from-ansible", "--output", str(output), "--ansible-inventory", str(ansible_inventory_path)]
+
+ # Set --ansible-group
+ if ansible_group is not None:
+ cli_args.extend(["--ansible-group", ansible_group])
+
+ result = click_runner.invoke(anta, cli_args)
+
+ assert result.exit_code == expected_exit
+
+ if expected_exit != ExitCode.OK:
+ assert expected_log
+ assert expected_log in result.output
+ else:
+ assert output.exists()
+ # TODO check size of generated inventory to validate the group functionality!
+
+
+@pytest.mark.parametrize(
+ "env_set, overwrite, is_tty, prompt, expected_exit, expected_log",
+ [
+ pytest.param(True, False, True, "y", ExitCode.OK, "", id="no-overwrite-tty-init-prompt-yes"),
+ pytest.param(True, False, True, "N", ExitCode.INTERNAL_ERROR, "Aborted", id="no-overwrite-tty-init-prompt-no"),
+ pytest.param(
+ True,
+ False,
+ False,
+ None,
+ ExitCode.USAGE_ERROR,
+ "Conversion aborted since destination file is not empty (not running in interactive TTY)",
+ id="no-overwrite-no-tty-init",
+ ),
+ pytest.param(False, False, True, None, ExitCode.OK, "", id="no-overwrite-tty-no-init"),
+ pytest.param(False, False, False, None, ExitCode.OK, "", id="no-overwrite-no-tty-no-init"),
+ pytest.param(True, True, True, None, ExitCode.OK, "", id="overwrite-tty-init"),
+ pytest.param(True, True, False, None, ExitCode.OK, "", id="overwrite-no-tty-init"),
+ pytest.param(False, True, True, None, ExitCode.OK, "", id="overwrite-tty-no-init"),
+ pytest.param(False, True, False, None, ExitCode.OK, "", id="overwrite-no-tty-no-init"),
+ ],
+)
+def test_from_ansible_overwrite(
+ tmp_path: Path,
+ click_runner: CliRunner,
+ temp_env: dict[str, str | None],
+ env_set: bool,
+ overwrite: bool,
+ is_tty: bool,
+ prompt: str | None,
+ expected_exit: int,
+ expected_log: str | None,
+) -> None:
+ # pylint: disable=too-many-arguments
+ """
+ Test `anta get from-ansible` overwrite mechanism
+
+ The test uses a static ansible-inventory and output as these are tested in other functions
+
+ This test verifies:
+ * that overwrite is working as expected with or without init data in the target file
+ * that when the target file is not empty and a tty is present, the user is prompt with confirmation
+ * Check the behavior when the prompt is filled
+
+ The initial content of the ANTA inventory is set using init_anta_inventory, if it is None, no inventory is set.
+
+ * With overwrite True, the expectation is that the from-ansible command succeeds
+ * With no init (init_anta_inventory == None), the expectation is also that command succeeds
+ """
+ ansible_inventory_path = DATA_DIR / "ansible_inventory.yml"
+ expected_anta_inventory_path = DATA_DIR / "expected_anta_inventory.yml"
+ tmp_output = tmp_path / "output.yml"
+ cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path)]
+
+ if env_set:
+ tmp_inv = Path(str(temp_env["ANTA_INVENTORY"]))
+ else:
+ temp_env["ANTA_INVENTORY"] = None
+ tmp_inv = tmp_output
+ cli_args.extend(["--output", str(tmp_output)])
+
+ if overwrite:
+ cli_args.append("--overwrite")
+
+ # Verify initial content is different
+ if tmp_inv.exists():
+ assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path)
+
+ with patch("sys.stdin.isatty", return_value=is_tty):
+ result = click_runner.invoke(anta, cli_args, env=temp_env, input=prompt)
+
+ assert result.exit_code == expected_exit
+ if expected_exit == ExitCode.OK:
+ assert filecmp.cmp(tmp_inv, expected_anta_inventory_path)
+ elif expected_exit == ExitCode.INTERNAL_ERROR:
+ assert expected_log
+ assert expected_log in result.output
diff --git a/tests/units/cli/get/test_utils.py b/tests/units/cli/get/test_utils.py
new file mode 100644
index 0000000..b335880
--- /dev/null
+++ b/tests/units/cli/get/test_utils.py
@@ -0,0 +1,115 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.get.utils
+"""
+from __future__ import annotations
+
+from contextlib import nullcontext
+from pathlib import Path
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from anta.cli.get.utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token
+from anta.inventory import AntaInventory
+
+DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data"
+
+
+def test_get_cv_token() -> None:
+ """
+ Test anta.get.utils.get_cv_token
+ """
+ ip = "42.42.42.42"
+ username = "ant"
+ password = "formica"
+
+ with patch("anta.cli.get.utils.requests.request") as patched_request:
+ mocked_ret = MagicMock(autospec=requests.Response)
+ mocked_ret.json.return_value = {"sessionId": "simple"}
+ patched_request.return_value = mocked_ret
+ res = get_cv_token(ip, username, password)
+ patched_request.assert_called_once_with(
+ "POST",
+ "https://42.42.42.42/cvpservice/login/authenticate.do",
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
+ data='{"userId": "ant", "password": "formica"}',
+ verify=False,
+ timeout=10,
+ )
+ assert res == "simple"
+
+
+# truncated inventories
+CVP_INVENTORY = [
+ {
+ "hostname": "device1",
+ "containerName": "DC1",
+ "ipAddress": "10.20.20.97",
+ },
+ {
+ "hostname": "device2",
+ "containerName": "DC2",
+ "ipAddress": "10.20.20.98",
+ },
+ {
+ "hostname": "device3",
+ "containerName": "",
+ "ipAddress": "10.20.20.99",
+ },
+]
+
+
+@pytest.mark.parametrize(
+ "inventory",
+ [
+ pytest.param(CVP_INVENTORY, id="some container"),
+ pytest.param([], id="empty_inventory"),
+ ],
+)
+def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any]]) -> None:
+ """
+ Test anta.get.utils.create_inventory_from_cvp
+ """
+ output = tmp_path / "output.yml"
+
+ create_inventory_from_cvp(inventory, output)
+
+ assert output.exists()
+ # This validate the file structure ;)
+ inv = AntaInventory.parse(str(output), "user", "pass")
+ assert len(inv) == len(inventory)
+
+
+@pytest.mark.parametrize(
+ "inventory_filename, ansible_group, expected_raise, expected_inv_length",
+ [
+ pytest.param("ansible_inventory.yml", None, nullcontext(), 7, id="no group"),
+ pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), 4, id="group found"),
+ pytest.param("ansible_inventory.yml", "DUMMY", pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"), 0, id="group not found"),
+ pytest.param("empty_ansible_inventory.yml", None, pytest.raises(ValueError, match="Ansible inventory .* is empty"), 0, id="empty inventory"),
+ pytest.param("wrong_ansible_inventory.yml", None, pytest.raises(ValueError, match="Could not parse"), 0, id="os error inventory"),
+ ],
+)
+def test_create_inventory_from_ansible(tmp_path: Path, inventory_filename: Path, ansible_group: str | None, expected_raise: Any, expected_inv_length: int) -> None:
+ """
+ Test anta.get.utils.create_inventory_from_ansible
+ """
+ target_file = tmp_path / "inventory.yml"
+ inventory_file_path = DATA_DIR / inventory_filename
+
+ with expected_raise:
+ if ansible_group:
+ create_inventory_from_ansible(inventory_file_path, target_file, ansible_group)
+ else:
+ create_inventory_from_ansible(inventory_file_path, target_file)
+
+ assert target_file.exists()
+ inv = AntaInventory().parse(str(target_file), "user", "pass")
+ assert len(inv) == expected_inv_length
+ if not isinstance(expected_raise, nullcontext):
+ assert not target_file.exists()
diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/cli/nrfu/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py
new file mode 100644
index 0000000..fea641c
--- /dev/null
+++ b/tests/units/cli/nrfu/test__init__.py
@@ -0,0 +1,111 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.nrfu
+"""
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+from tests.lib.utils import default_anta_env
+
+# TODO: write unit tests for ignore-status and ignore-error
+
+
+def test_anta_nrfu_help(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu --help
+ """
+ result = click_runner.invoke(anta, ["nrfu", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta nrfu" in result.output
+
+
+def test_anta_nrfu(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu, catalog is given via env
+ """
+ result = click_runner.invoke(anta, ["nrfu"])
+ assert result.exit_code == ExitCode.OK
+ assert "ANTA Inventory contains 3 devices" in result.output
+ assert "Tests catalog contains 1 tests" in result.output
+
+
+def test_anta_password_required(click_runner: CliRunner) -> None:
+ """
+ Test that password is provided
+ """
+ env = default_anta_env()
+ env["ANTA_PASSWORD"] = None
+ result = click_runner.invoke(anta, ["nrfu"], env=env)
+
+ assert result.exit_code == ExitCode.USAGE_ERROR
+ assert "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." in result.output
+
+
+def test_anta_password(click_runner: CliRunner) -> None:
+ """
+ Test that password can be provided either via --password or --prompt
+ """
+ env = default_anta_env()
+ env["ANTA_PASSWORD"] = None
+ result = click_runner.invoke(anta, ["nrfu", "--password", "secret"], env=env)
+ assert result.exit_code == ExitCode.OK
+ result = click_runner.invoke(anta, ["nrfu", "--prompt"], input="password\npassword\n", env=env)
+ assert result.exit_code == ExitCode.OK
+
+
+def test_anta_enable_password(click_runner: CliRunner) -> None:
+ """
+ Test that enable password can be provided either via --enable-password or --prompt
+ """
+ # Both enable and enable-password
+ result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret"])
+ assert result.exit_code == ExitCode.OK
+
+ # enable and prompt y
+ result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="y\npassword\npassword\n")
+ assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output
+ assert "Please enter a password to enter EOS privileged EXEC mode" in result.output
+ assert result.exit_code == ExitCode.OK
+
+ # enable and prompt N
+ result = click_runner.invoke(anta, ["nrfu", "--enable", "--prompt"], input="N\n")
+ assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" in result.output
+ assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output
+ assert result.exit_code == ExitCode.OK
+
+ # enable and enable-password and prompt (redundant)
+ result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt"], input="y\npassword\npassword\n")
+ assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output
+ assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output
+ assert result.exit_code == ExitCode.OK
+
+ # enabled-password without enable
+ result = click_runner.invoke(anta, ["nrfu", "--enable-password", "blah"])
+ assert result.exit_code == ExitCode.USAGE_ERROR
+ assert "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." in result.output
+
+
+def test_anta_enable_alone(click_runner: CliRunner) -> None:
+ """
+ Test that enable can be provided either without enable-password
+ """
+ result = click_runner.invoke(anta, ["nrfu", "--enable"])
+ assert result.exit_code == ExitCode.OK
+
+
+def test_disable_cache(click_runner: CliRunner) -> None:
+ """
+ Test that disable_cache is working on inventory
+ """
+ result = click_runner.invoke(anta, ["nrfu", "--disable-cache"])
+ stdout_lines = result.stdout.split("\n")
+ # All caches should be disabled from the inventory
+ for line in stdout_lines:
+ if "disable_cache" in line:
+ assert "True" in line
+ assert result.exit_code == ExitCode.OK
diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py
new file mode 100644
index 0000000..4639671
--- /dev/null
+++ b/tests/units/cli/nrfu/test_commands.py
@@ -0,0 +1,97 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.nrfu.commands
+"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+DATA_DIR: Path = Path(__file__).parent.parent.parent.parent.resolve() / "data"
+
+
+def test_anta_nrfu_table_help(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu table --help
+ """
+ result = click_runner.invoke(anta, ["nrfu", "table", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta nrfu table" in result.output
+
+
+def test_anta_nrfu_text_help(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu text --help
+ """
+ result = click_runner.invoke(anta, ["nrfu", "text", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta nrfu text" in result.output
+
+
+def test_anta_nrfu_json_help(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu json --help
+ """
+ result = click_runner.invoke(anta, ["nrfu", "json", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta nrfu json" in result.output
+
+
+def test_anta_nrfu_template_help(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu tpl-report --help
+ """
+ result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta nrfu tpl-report" in result.output
+
+
+def test_anta_nrfu_table(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu, catalog is given via env
+ """
+ result = click_runner.invoke(anta, ["nrfu", "table"])
+ assert result.exit_code == ExitCode.OK
+ assert "dummy │ VerifyEOSVersion │ success" in result.output
+
+
+def test_anta_nrfu_text(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu, catalog is given via env
+ """
+ result = click_runner.invoke(anta, ["nrfu", "text"])
+ assert result.exit_code == ExitCode.OK
+ assert "dummy :: VerifyEOSVersion :: SUCCESS" in result.output
+
+
+def test_anta_nrfu_json(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu, catalog is given via env
+ """
+ result = click_runner.invoke(anta, ["nrfu", "json"])
+ assert result.exit_code == ExitCode.OK
+ assert "JSON results of all tests" in result.output
+ m = re.search(r"\[\n {[\s\S]+ }\n\]", result.output)
+ assert m is not None
+ result_list = json.loads(m.group())
+ for r in result_list:
+ if r["name"] == "dummy":
+ assert r["test"] == "VerifyEOSVersion"
+ assert r["result"] == "success"
+
+
+def test_anta_nrfu_template(click_runner: CliRunner) -> None:
+ """
+ Test anta nrfu, catalog is given via env
+ """
+ result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")])
+ assert result.exit_code == ExitCode.OK
+ assert "* VerifyEOSVersion is SUCCESS for dummy" in result.output
diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py
new file mode 100644
index 0000000..0e84e14
--- /dev/null
+++ b/tests/units/cli/test__init__.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.cli.__init__
+"""
+
+from __future__ import annotations
+
+from click.testing import CliRunner
+
+from anta.cli import anta
+from anta.cli.utils import ExitCode
+
+
+def test_anta(click_runner: CliRunner) -> None:
+ """
+ Test anta main entrypoint
+ """
+ result = click_runner.invoke(anta)
+ assert result.exit_code == ExitCode.OK
+ assert "Usage" in result.output
+
+
+def test_anta_help(click_runner: CliRunner) -> None:
+ """
+ Test anta --help
+ """
+ result = click_runner.invoke(anta, ["--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage" in result.output
+
+
+def test_anta_exec_help(click_runner: CliRunner) -> None:
+ """
+ Test anta exec --help
+ """
+ result = click_runner.invoke(anta, ["exec", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta exec" in result.output
+
+
+def test_anta_debug_help(click_runner: CliRunner) -> None:
+ """
+ Test anta debug --help
+ """
+ result = click_runner.invoke(anta, ["debug", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta debug" in result.output
+
+
+def test_anta_get_help(click_runner: CliRunner) -> None:
+ """
+ Test anta get --help
+ """
+ result = click_runner.invoke(anta, ["get", "--help"])
+ assert result.exit_code == ExitCode.OK
+ assert "Usage: anta get" in result.output
diff --git a/tests/units/inventory/__init__.py b/tests/units/inventory/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/inventory/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/inventory/test_inventory.py b/tests/units/inventory/test_inventory.py
new file mode 100644
index 0000000..7c62b5c
--- /dev/null
+++ b/tests/units/inventory/test_inventory.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""ANTA Inventory unit tests."""
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+from pydantic import ValidationError
+
+from anta.inventory import AntaInventory
+from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError
+from tests.data.json_data import ANTA_INVENTORY_TESTS_INVALID, ANTA_INVENTORY_TESTS_VALID
+from tests.lib.utils import generate_test_ids_dict
+
+
+class Test_AntaInventory:
+ """Test AntaInventory class."""
+
+ def create_inventory(self, content: str, tmp_path: Path) -> str:
+ """Create fakefs inventory file."""
+ tmp_inventory = tmp_path / "mydir/myfile"
+ tmp_inventory.parent.mkdir()
+ tmp_inventory.touch()
+ tmp_inventory.write_text(yaml.dump(content, allow_unicode=True))
+ return str(tmp_inventory)
+
+ def check_parameter(self, parameter: str, test_definition: dict[Any, Any]) -> bool:
+ """Check if parameter is configured in testbed."""
+ return "parameters" in test_definition and parameter in test_definition["parameters"].keys()
+
+ @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_VALID, ids=generate_test_ids_dict)
+ def test_init_valid(self, test_definition: dict[str, Any], tmp_path: Path) -> None:
+ """Test class constructor with valid data.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidInventory_with_host_only',
+ 'input': {"anta_inventory":{"hosts":[{"host":"192.168.0.17"},{"host":"192.168.0.2"}]}},
+ 'expected_result': 'valid',
+ 'parameters': {
+ 'ipaddress_in_scope': '192.168.0.17',
+ 'ipaddress_out_of_scope': '192.168.1.1',
+ }
+ }
+
+ """
+ inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path)
+ try:
+ AntaInventory.parse(filename=inventory_file, username="arista", password="arista123")
+ except ValidationError as exc:
+ logging.error("Exceptions is: %s", str(exc))
+ assert False
+
+ @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_INVALID, ids=generate_test_ids_dict)
+ def test_init_invalid(self, test_definition: dict[str, Any], tmp_path: Path) -> None:
+ """Test class constructor with invalid data.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidInventory_with_host_only',
+ 'input': {"anta_inventory":{"hosts":[{"host":"192.168.0.17"},{"host":"192.168.0.2"}]}},
+ 'expected_result': 'invalid',
+ 'parameters': {
+ 'ipaddress_in_scope': '192.168.0.17',
+ 'ipaddress_out_of_scope': '192.168.1.1',
+ }
+ }
+
+ """
+ inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path)
+ with pytest.raises((InventoryIncorrectSchema, InventoryRootKeyError, ValidationError)):
+ AntaInventory.parse(filename=inventory_file, username="arista", password="arista123")
diff --git a/tests/units/inventory/test_models.py b/tests/units/inventory/test_models.py
new file mode 100644
index 0000000..83f151c
--- /dev/null
+++ b/tests/units/inventory/test_models.py
@@ -0,0 +1,393 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""ANTA Inventory models unit tests."""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import pytest
+from pydantic import ValidationError
+
+from anta.device import AsyncEOSDevice
+from anta.inventory.models import AntaInventoryHost, AntaInventoryInput, AntaInventoryNetwork, AntaInventoryRange
+from tests.data.json_data import (
+ INVENTORY_DEVICE_MODEL_INVALID,
+ INVENTORY_DEVICE_MODEL_VALID,
+ INVENTORY_MODEL_HOST_CACHE,
+ INVENTORY_MODEL_HOST_INVALID,
+ INVENTORY_MODEL_HOST_VALID,
+ INVENTORY_MODEL_INVALID,
+ INVENTORY_MODEL_NETWORK_CACHE,
+ INVENTORY_MODEL_NETWORK_INVALID,
+ INVENTORY_MODEL_NETWORK_VALID,
+ INVENTORY_MODEL_RANGE_CACHE,
+ INVENTORY_MODEL_RANGE_INVALID,
+ INVENTORY_MODEL_RANGE_VALID,
+ INVENTORY_MODEL_VALID,
+)
+from tests.lib.utils import generate_test_ids_dict
+
+
+class Test_InventoryUnitModels:
+ """Test components of AntaInventoryInput model."""
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_VALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_host_valid(self, test_definition: dict[str, Any]) -> None:
+ """Test host input model.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidIPv4_Host',
+ 'input': '1.1.1.1',
+ 'expected_result': 'valid'
+ }
+
+ """
+ try:
+ host_inventory = AntaInventoryHost(host=test_definition["input"])
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ assert False
+ else:
+ assert test_definition["input"] == str(host_inventory.host)
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_INVALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_host_invalid(self, test_definition: dict[str, Any]) -> None:
+ """Test host input model.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidIPv4_Host',
+ 'input': '1.1.1.1/32',
+ 'expected_result': 'invalid'
+ }
+
+ """
+ with pytest.raises(ValidationError):
+ AntaInventoryHost(host=test_definition["input"])
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_CACHE, ids=generate_test_ids_dict)
+ def test_anta_inventory_host_cache(self, test_definition: dict[str, Any]) -> None:
+ """Test host disable_cache.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'Cache',
+ 'input': {"host": '1.1.1.1', "disable_cache": True},
+ 'expected_result': True
+ }
+
+ """
+ if "disable_cache" in test_definition["input"]:
+ host_inventory = AntaInventoryHost(host=test_definition["input"]["host"], disable_cache=test_definition["input"]["disable_cache"])
+ else:
+ host_inventory = AntaInventoryHost(host=test_definition["input"]["host"])
+ assert test_definition["expected_result"] == host_inventory.disable_cache
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_VALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_network_valid(self, test_definition: dict[str, Any]) -> None:
+ """Test Network input model with valid data.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidIPv4_Subnet',
+ 'input': '1.1.1.0/24',
+ 'expected_result': 'valid'
+ }
+
+ """
+ try:
+ network_inventory = AntaInventoryNetwork(network=test_definition["input"])
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ assert False
+ else:
+ assert test_definition["input"] == str(network_inventory.network)
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_INVALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_network_invalid(self, test_definition: dict[str, Any]) -> None:
+ """Test Network input model with invalid data.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidIPv4_Subnet',
+ 'input': '1.1.1.0/16',
+ 'expected_result': 'invalid'
+ }
+
+ """
+ try:
+ AntaInventoryNetwork(network=test_definition["input"])
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ else:
+ assert False
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_CACHE, ids=generate_test_ids_dict)
+ def test_anta_inventory_network_cache(self, test_definition: dict[str, Any]) -> None:
+ """Test network disable_cache
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'Cache',
+ 'input': {"network": '1.1.1.1/24', "disable_cache": True},
+ 'expected_result': True
+ }
+
+ """
+ if "disable_cache" in test_definition["input"]:
+ network_inventory = AntaInventoryNetwork(network=test_definition["input"]["network"], disable_cache=test_definition["input"]["disable_cache"])
+ else:
+ network_inventory = AntaInventoryNetwork(network=test_definition["input"]["network"])
+ assert test_definition["expected_result"] == network_inventory.disable_cache
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_VALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_range_valid(self, test_definition: dict[str, Any]) -> None:
+ """Test range input model.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidIPv4_Range',
+ 'input': {'start':'10.1.0.1', 'end':'10.1.0.10'},
+ 'expected_result': 'valid'
+ }
+
+ """
+ try:
+ range_inventory = AntaInventoryRange(
+ start=test_definition["input"]["start"],
+ end=test_definition["input"]["end"],
+ )
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ assert False
+ else:
+ assert test_definition["input"]["start"] == str(range_inventory.start)
+ assert test_definition["input"]["end"] == str(range_inventory.end)
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_INVALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_range_invalid(self, test_definition: dict[str, Any]) -> None:
+ """Test range input model.
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'ValidIPv4_Range',
+ 'input': {'start':'10.1.0.1', 'end':'10.1.0.10/32'},
+ 'expected_result': 'invalid'
+ }
+
+ """
+ try:
+ AntaInventoryRange(
+ start=test_definition["input"]["start"],
+ end=test_definition["input"]["end"],
+ )
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ else:
+ assert False
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_CACHE, ids=generate_test_ids_dict)
+ def test_anta_inventory_range_cache(self, test_definition: dict[str, Any]) -> None:
+ """Test range disable_cache
+
+ Test structure:
+ ---------------
+
+ {
+ 'name': 'Cache',
+ 'input': {"start": '1.1.1.1', "end": "1.1.1.10", "disable_cache": True},
+ 'expected_result': True
+ }
+
+ """
+ if "disable_cache" in test_definition["input"]:
+ range_inventory = AntaInventoryRange(
+ start=test_definition["input"]["start"], end=test_definition["input"]["end"], disable_cache=test_definition["input"]["disable_cache"]
+ )
+ else:
+ range_inventory = AntaInventoryRange(start=test_definition["input"]["start"], end=test_definition["input"]["end"])
+ assert test_definition["expected_result"] == range_inventory.disable_cache
+
+
+class Test_AntaInventoryInputModel:
+ """Unit test of AntaInventoryInput model."""
+
+ def test_inventory_input_structure(self) -> None:
+ """Test inventory keys are those expected."""
+
+ inventory = AntaInventoryInput()
+ logging.info("Inventory keys are: %s", str(inventory.model_dump().keys()))
+ assert all(elem in inventory.model_dump().keys() for elem in ["hosts", "networks", "ranges"])
+
+ @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_VALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_intput_valid(self, inventory_def: dict[str, Any]) -> None:
+ """Test loading valid data to inventory class.
+
+ Test structure:
+ ---------------
+
+ {
+ "name": "Valid_Host_Only",
+ "input": {
+ "hosts": [
+ {
+ "host": "192.168.0.17"
+ },
+ {
+ "host": "192.168.0.2"
+ }
+ ]
+ },
+ "expected_result": "valid"
+ }
+
+ """
+ try:
+ inventory = AntaInventoryInput(**inventory_def["input"])
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ assert False
+ else:
+ logging.info("Checking if all root keys are correctly lodaded")
+ assert all(elem in inventory.model_dump().keys() for elem in inventory_def["input"].keys())
+
+ @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_INVALID, ids=generate_test_ids_dict)
+ def test_anta_inventory_intput_invalid(self, inventory_def: dict[str, Any]) -> None:
+ """Test loading invalid data to inventory class.
+
+ Test structure:
+ ---------------
+
+ {
+ "name": "Valid_Host_Only",
+ "input": {
+ "hosts": [
+ {
+ "host": "192.168.0.17"
+ },
+ {
+ "host": "192.168.0.2/32"
+ }
+ ]
+ },
+ "expected_result": "invalid"
+ }
+
+ """
+ try:
+ if "hosts" in inventory_def["input"].keys():
+ logging.info(
+ "Loading %s into AntaInventoryInput hosts section",
+ str(inventory_def["input"]["hosts"]),
+ )
+ AntaInventoryInput(hosts=inventory_def["input"]["hosts"])
+ if "networks" in inventory_def["input"].keys():
+ logging.info(
+ "Loading %s into AntaInventoryInput networks section",
+ str(inventory_def["input"]["networks"]),
+ )
+ AntaInventoryInput(networks=inventory_def["input"]["networks"])
+ if "ranges" in inventory_def["input"].keys():
+ logging.info(
+ "Loading %s into AntaInventoryInput ranges section",
+ str(inventory_def["input"]["ranges"]),
+ )
+ AntaInventoryInput(ranges=inventory_def["input"]["ranges"])
+ except ValidationError as exc:
+ logging.warning("Error: %s", str(exc))
+ else:
+ assert False
+
+
+class Test_InventoryDeviceModel:
+ """Unit test of InventoryDevice model."""
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_VALID, ids=generate_test_ids_dict)
+ def test_inventory_device_valid(self, test_definition: dict[str, Any]) -> None:
+ """Test loading valid data to InventoryDevice class.
+
+ Test structure:
+ ---------------
+
+ {
+ "name": "Valid_Inventory",
+ "input": [
+ {
+ 'host': '1.1.1.1',
+ 'username': 'arista',
+ 'password': 'arista123!'
+ },
+ {
+ 'host': '1.1.1.1',
+ 'username': 'arista',
+ 'password': 'arista123!'
+ }
+ ],
+ "expected_result": "valid"
+ }
+
+ """
+ if test_definition["expected_result"] == "invalid":
+ pytest.skip("Not concerned by the test")
+
+ for entity in test_definition["input"]:
+ try:
+ AsyncEOSDevice(**entity)
+ except TypeError as exc:
+ logging.warning("Error: %s", str(exc))
+ assert False
+
+ @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_INVALID, ids=generate_test_ids_dict)
+ def test_inventory_device_invalid(self, test_definition: dict[str, Any]) -> None:
+ """Test loading invalid data to InventoryDevice class.
+
+ Test structure:
+ ---------------
+
+ {
+ "name": "Valid_Inventory",
+ "input": [
+ {
+ 'host': '1.1.1.1',
+ 'username': 'arista',
+ 'password': 'arista123!'
+ },
+ {
+ 'host': '1.1.1.1',
+ 'username': 'arista',
+ 'password': 'arista123!'
+ }
+ ],
+ "expected_result": "valid"
+ }
+
+ """
+ if test_definition["expected_result"] == "valid":
+ pytest.skip("Not concerned by the test")
+
+ for entity in test_definition["input"]:
+ try:
+ AsyncEOSDevice(**entity)
+ except TypeError as exc:
+ logging.info("Error: %s", str(exc))
+ else:
+ assert False
diff --git a/tests/units/reporter/__init__.py b/tests/units/reporter/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/reporter/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/reporter/test__init__.py b/tests/units/reporter/test__init__.py
new file mode 100644
index 0000000..259942f
--- /dev/null
+++ b/tests/units/reporter/test__init__.py
@@ -0,0 +1,193 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Test anta.report.__init__.py
+"""
+from __future__ import annotations
+
+from typing import Callable
+
+import pytest
+from rich.table import Table
+
+from anta import RICH_COLOR_PALETTE
+from anta.custom_types import TestStatus
+from anta.reporter import ReportTable
+from anta.result_manager import ResultManager
+
+
+class Test_ReportTable:
+ """
+ Test ReportTable class
+ """
+
+ # not testing __init__ as nothing is going on there
+
+ @pytest.mark.parametrize(
+ "usr_list, delimiter, expected_output",
+ [
+ pytest.param([], None, "", id="empty list no delimiter"),
+ pytest.param([], "*", "", id="empty list with delimiter"),
+ pytest.param(["elem1"], None, "elem1", id="one elem list no delimiter"),
+ pytest.param(["elem1"], "*", "* elem1", id="one elem list with delimiter"),
+ pytest.param(["elem1", "elem2"], None, "elem1\nelem2", id="two elems list no delimiter"),
+ pytest.param(["elem1", "elem2"], "&", "& elem1\n& elem2", id="two elems list with delimiter"),
+ ],
+ )
+ def test__split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None, expected_output: str) -> None:
+ """
+ test _split_list_to_txt_list
+ """
+ # pylint: disable=protected-access
+ report = ReportTable()
+ assert report._split_list_to_txt_list(usr_list, delimiter) == expected_output
+
+ @pytest.mark.parametrize(
+ "headers",
+ [
+ pytest.param([], id="empty list"),
+ pytest.param(["elem1"], id="one elem list"),
+ pytest.param(["elem1", "elem2"], id="two elemst"),
+ ],
+ )
+ def test__build_headers(self, headers: list[str]) -> None:
+ """
+ test _build_headers
+ """
+ # pylint: disable=protected-access
+ report = ReportTable()
+ table = Table()
+ table_column_before = len(table.columns)
+ report._build_headers(headers, table)
+ assert len(table.columns) == table_column_before + len(headers)
+ if len(table.columns) > 0:
+ assert table.columns[table_column_before].style == RICH_COLOR_PALETTE.HEADER
+
+ @pytest.mark.parametrize(
+ "status, expected_status",
+ [
+ pytest.param("unknown", "unknown", id="unknown status"),
+ pytest.param("unset", "[grey74]unset", id="unset status"),
+ pytest.param("skipped", "[bold orange4]skipped", id="skipped status"),
+ pytest.param("failure", "[bold red]failure", id="failure status"),
+ pytest.param("error", "[indian_red]error", id="error status"),
+ pytest.param("success", "[green4]success", id="success status"),
+ ],
+ )
+ def test__color_result(self, status: TestStatus, expected_status: str) -> None:
+ """
+ test _build_headers
+ """
+ # pylint: disable=protected-access
+ report = ReportTable()
+ assert report._color_result(status) == expected_status
+
+ @pytest.mark.parametrize(
+ "host, testcase, title, number_of_tests, expected_length",
+ [
+ pytest.param(None, None, None, 5, 5, id="all results"),
+ pytest.param("host1", None, None, 5, 0, id="result for host1 when no host1 test"),
+ pytest.param(None, "VerifyTest3", None, 5, 1, id="result for test VerifyTest3"),
+ pytest.param(None, None, "Custom title", 5, 5, id="Change table title"),
+ ],
+ )
+ def test_report_all(
+ self,
+ result_manager_factory: Callable[[int], ResultManager],
+ host: str | None,
+ testcase: str | None,
+ title: str | None,
+ number_of_tests: int,
+ expected_length: int,
+ ) -> None:
+ """
+ test report_all
+ """
+ # pylint: disable=too-many-arguments
+ rm = result_manager_factory(number_of_tests)
+
+ report = ReportTable()
+ kwargs = {"host": host, "testcase": testcase, "title": title}
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ res = report.report_all(rm, **kwargs) # type: ignore[arg-type]
+
+ assert isinstance(res, Table)
+ assert res.title == (title or "All tests results")
+ assert res.row_count == expected_length
+
+ @pytest.mark.parametrize(
+ "testcase, title, number_of_tests, expected_length",
+ [
+ pytest.param(None, None, 5, 5, id="all results"),
+ pytest.param("VerifyTest3", None, 5, 1, id="result for test VerifyTest3"),
+ pytest.param(None, "Custom title", 5, 5, id="Change table title"),
+ ],
+ )
+ def test_report_summary_tests(
+ self,
+ result_manager_factory: Callable[[int], ResultManager],
+ testcase: str | None,
+ title: str | None,
+ number_of_tests: int,
+ expected_length: int,
+ ) -> None:
+ """
+ test report_summary_tests
+ """
+ # pylint: disable=too-many-arguments
+ # TODO refactor this later... this is injecting double test results by modyfing the device name
+ # should be a fixture
+ rm = result_manager_factory(number_of_tests)
+ new_results = [result.model_copy() for result in rm.get_results()]
+ for result in new_results:
+ result.name = "test_device"
+ result.result = "failure"
+ rm.add_test_results(new_results)
+
+ report = ReportTable()
+ kwargs = {"testcase": testcase, "title": title}
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ res = report.report_summary_tests(rm, **kwargs) # type: ignore[arg-type]
+
+ assert isinstance(res, Table)
+ assert res.title == (title or "Summary per test case")
+ assert res.row_count == expected_length
+
+ @pytest.mark.parametrize(
+ "host, title, number_of_tests, expected_length",
+ [
+ pytest.param(None, None, 5, 2, id="all results"),
+ pytest.param("host1", None, 5, 1, id="result for host host1"),
+ pytest.param(None, "Custom title", 5, 2, id="Change table title"),
+ ],
+ )
+ def test_report_summary_hosts(
+ self,
+ result_manager_factory: Callable[[int], ResultManager],
+ host: str | None,
+ title: str | None,
+ number_of_tests: int,
+ expected_length: int,
+ ) -> None:
+ """
+ test report_summary_hosts
+ """
+ # pylint: disable=too-many-arguments
+ # TODO refactor this later... this is injecting double test results by modyfing the device name
+ # should be a fixture
+ rm = result_manager_factory(number_of_tests)
+ new_results = [result.model_copy() for result in rm.get_results()]
+ for result in new_results:
+ result.name = host or "test_device"
+ result.result = "failure"
+ rm.add_test_results(new_results)
+
+ report = ReportTable()
+ kwargs = {"host": host, "title": title}
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ res = report.report_summary_hosts(rm, **kwargs) # type: ignore[arg-type]
+
+ assert isinstance(res, Table)
+ assert res.title == (title or "Summary per host")
+ assert res.row_count == expected_length
diff --git a/tests/units/result_manager/__init__.py b/tests/units/result_manager/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/result_manager/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py
new file mode 100644
index 0000000..c457c84
--- /dev/null
+++ b/tests/units/result_manager/test__init__.py
@@ -0,0 +1,204 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Test anta.result_manager.__init__.py
+"""
+from __future__ import annotations
+
+import json
+from contextlib import nullcontext
+from typing import TYPE_CHECKING, Any, Callable
+
+import pytest
+
+from anta.custom_types import TestStatus
+from anta.result_manager import ResultManager
+
+if TYPE_CHECKING:
+ from anta.result_manager.models import TestResult
+
+
+class Test_ResultManager:
+ """
+ Test ResultManager class
+ """
+
+ # not testing __init__ as nothing is going on there
+
+ def test__len__(self, list_result_factory: Callable[[int], list[TestResult]]) -> None:
+ """
+ test __len__
+ """
+ list_result = list_result_factory(3)
+ result_manager = ResultManager()
+ assert len(result_manager) == 0
+ for i in range(3):
+ result_manager.add_test_result(list_result[i])
+ assert len(result_manager) == i + 1
+
+ @pytest.mark.parametrize(
+ "starting_status, test_status, expected_status, expected_raise",
+ [
+ pytest.param("unset", "unset", "unset", nullcontext(), id="unset->unset"),
+ pytest.param("unset", "success", "success", nullcontext(), id="unset->success"),
+ pytest.param("unset", "error", "unset", nullcontext(), id="set error"),
+ pytest.param("skipped", "skipped", "skipped", nullcontext(), id="skipped->skipped"),
+ pytest.param("skipped", "unset", "skipped", nullcontext(), id="skipped, add unset"),
+ pytest.param("skipped", "success", "success", nullcontext(), id="skipped, add success"),
+ pytest.param("skipped", "failure", "failure", nullcontext(), id="skipped, add failure"),
+ pytest.param("success", "unset", "success", nullcontext(), id="success, add unset"),
+ pytest.param("success", "skipped", "success", nullcontext(), id="success, add skipped"),
+ pytest.param("success", "success", "success", nullcontext(), id="success->success"),
+ pytest.param("success", "failure", "failure", nullcontext(), id="success->failure"),
+ pytest.param("failure", "unset", "failure", nullcontext(), id="failure->failure"),
+ pytest.param("failure", "skipped", "failure", nullcontext(), id="failure, add unset"),
+ pytest.param("failure", "success", "failure", nullcontext(), id="failure, add skipped"),
+ pytest.param("failure", "failure", "failure", nullcontext(), id="failure, add success"),
+ pytest.param("unset", "unknown", None, pytest.raises(ValueError), id="wrong status"),
+ ],
+ )
+ def test__update_status(self, starting_status: TestStatus, test_status: TestStatus, expected_status: str, expected_raise: Any) -> None:
+ """
+ Test ResultManager._update_status
+ """
+ result_manager = ResultManager()
+ result_manager.status = starting_status
+ assert result_manager.error_status is False
+
+ with expected_raise:
+ result_manager._update_status(test_status) # pylint: disable=protected-access
+ if test_status == "error":
+ assert result_manager.error_status is True
+ else:
+ assert result_manager.status == expected_status
+
+ def test_add_test_result(self, test_result_factory: Callable[[int], TestResult]) -> None:
+ """
+ Test ResultManager.add_test_result
+ """
+ result_manager = ResultManager()
+ assert result_manager.status == "unset"
+ assert result_manager.error_status is False
+ assert len(result_manager) == 0
+
+ # Add one unset test
+ unset_test = test_result_factory(0)
+ unset_test.result = "unset"
+ result_manager.add_test_result(unset_test)
+ assert result_manager.status == "unset"
+ assert result_manager.error_status is False
+ assert len(result_manager) == 1
+
+ # Add one success test
+ success_test = test_result_factory(1)
+ success_test.result = "success"
+ result_manager.add_test_result(success_test)
+ assert result_manager.status == "success"
+ assert result_manager.error_status is False
+ assert len(result_manager) == 2
+
+ # Add one error test
+ error_test = test_result_factory(1)
+ error_test.result = "error"
+ result_manager.add_test_result(error_test)
+ assert result_manager.status == "success"
+ assert result_manager.error_status is True
+ assert len(result_manager) == 3
+
+ # Add one failure test
+ failure_test = test_result_factory(1)
+ failure_test.result = "failure"
+ result_manager.add_test_result(failure_test)
+ assert result_manager.status == "failure"
+ assert result_manager.error_status is True
+ assert len(result_manager) == 4
+
+ def test_add_test_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None:
+ """
+ Test ResultManager.add_test_results
+ """
+ result_manager = ResultManager()
+ assert result_manager.status == "unset"
+ assert result_manager.error_status is False
+ assert len(result_manager) == 0
+
+ # Add three success tests
+ success_list = list_result_factory(3)
+ for test in success_list:
+ test.result = "success"
+ result_manager.add_test_results(success_list)
+ assert result_manager.status == "success"
+ assert result_manager.error_status is False
+ assert len(result_manager) == 3
+
+ # Add one error test and one failure
+ error_failure_list = list_result_factory(2)
+ error_failure_list[0].result = "error"
+ error_failure_list[1].result = "failure"
+ result_manager.add_test_results(error_failure_list)
+ assert result_manager.status == "failure"
+ assert result_manager.error_status is True
+ assert len(result_manager) == 5
+
+ @pytest.mark.parametrize(
+ "status, error_status, ignore_error, expected_status",
+ [
+ pytest.param("success", False, True, "success", id="no error"),
+ pytest.param("success", True, True, "success", id="error, ignore error"),
+ pytest.param("success", True, False, "error", id="error, do not ignore error"),
+ ],
+ )
+ def test_get_status(self, status: TestStatus, error_status: bool, ignore_error: bool, expected_status: str) -> None:
+ """
+ test ResultManager.get_status
+ """
+ result_manager = ResultManager()
+ result_manager.status = status
+ result_manager.error_status = error_status
+
+ assert result_manager.get_status(ignore_error=ignore_error) == expected_status
+
+ def test_get_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None:
+ """
+ test ResultManager.get_results
+ """
+ result_manager = ResultManager()
+
+ success_list = list_result_factory(3)
+ for test in success_list:
+ test.result = "success"
+ result_manager.add_test_results(success_list)
+
+ res = result_manager.get_results()
+ assert isinstance(res, list)
+
+ def test_get_json_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None:
+ """
+ test ResultManager.get_json_results
+ """
+ result_manager = ResultManager()
+
+ success_list = list_result_factory(3)
+ for test in success_list:
+ test.result = "success"
+ result_manager.add_test_results(success_list)
+
+ json_res = result_manager.get_json_results()
+ assert isinstance(json_res, str)
+
+ # Verifies it can be deserialized back to a list of dict with the correct values types
+ res = json.loads(json_res)
+ for test in res:
+ assert isinstance(test, dict)
+ assert isinstance(test.get("test"), str)
+ assert isinstance(test.get("categories"), list)
+ assert isinstance(test.get("description"), str)
+ assert test.get("custom_field") is None
+ assert test.get("result") == "success"
+
+ # TODO
+ # get_result_by_test
+ # get_result_by_host
+ # get_testcases
+ # get_hosts
diff --git a/tests/units/result_manager/test_models.py b/tests/units/result_manager/test_models.py
new file mode 100644
index 0000000..bc7ba8a
--- /dev/null
+++ b/tests/units/result_manager/test_models.py
@@ -0,0 +1,57 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""ANTA Result Manager models unit tests."""
+from __future__ import annotations
+
+from typing import Any, Callable
+
+import pytest
+
+# Import as Result to avoid pytest collection
+from anta.result_manager.models import TestResult as Result
+from tests.data.json_data import TEST_RESULT_SET_STATUS
+from tests.lib.fixture import DEVICE_NAME
+from tests.lib.utils import generate_test_ids_dict
+
+
+class TestTestResultModels:
+ """Test components of anta.result_manager.models."""
+
+ @pytest.mark.parametrize("data", TEST_RESULT_SET_STATUS, ids=generate_test_ids_dict)
+ def test__is_status_foo(self, test_result_factory: Callable[[int], Result], data: dict[str, Any]) -> None:
+ """Test TestResult.is_foo methods."""
+ testresult = test_result_factory(1)
+ assert testresult.result == "unset"
+ assert len(testresult.messages) == 0
+ if data["target"] == "success":
+ testresult.is_success(data["message"])
+ assert testresult.result == data["target"]
+ assert data["message"] in testresult.messages
+ if data["target"] == "failure":
+ testresult.is_failure(data["message"])
+ assert testresult.result == data["target"]
+ assert data["message"] in testresult.messages
+ if data["target"] == "error":
+ testresult.is_error(data["message"])
+ assert testresult.result == data["target"]
+ assert data["message"] in testresult.messages
+ if data["target"] == "skipped":
+ testresult.is_skipped(data["message"])
+ assert testresult.result == data["target"]
+ assert data["message"] in testresult.messages
+ # no helper for unset, testing _set_status
+ if data["target"] == "unset":
+ testresult._set_status("unset", data["message"]) # pylint: disable=W0212
+ assert testresult.result == data["target"]
+ assert data["message"] in testresult.messages
+
+ @pytest.mark.parametrize("data", TEST_RESULT_SET_STATUS, ids=generate_test_ids_dict)
+ def test____str__(self, test_result_factory: Callable[[int], Result], data: dict[str, Any]) -> None:
+ """Test TestResult.__str__."""
+ testresult = test_result_factory(1)
+ assert testresult.result == "unset"
+ assert len(testresult.messages) == 0
+ testresult._set_status(data["target"], data["message"]) # pylint: disable=W0212
+ assert testresult.result == data["target"]
+ assert str(testresult) == f"Test 'VerifyTest1' (on '{DEVICE_NAME}'): Result '{data['target']}'\nMessages: {[data['message']]}"
diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py
new file mode 100644
index 0000000..22a2121
--- /dev/null
+++ b/tests/units/test_catalog.py
@@ -0,0 +1,311 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+test anta.device.py
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+from pydantic import ValidationError
+from yaml import safe_load
+
+from anta.catalog import AntaCatalog, AntaTestDefinition
+from anta.models import AntaTest
+from anta.tests.interfaces import VerifyL3MTU
+from anta.tests.mlag import VerifyMlagStatus
+from anta.tests.software import VerifyEOSVersion
+from anta.tests.system import (
+ VerifyAgentLogs,
+ VerifyCoredump,
+ VerifyCPUUtilization,
+ VerifyFileSystemUtilization,
+ VerifyMemoryUtilization,
+ VerifyNTP,
+ VerifyReloadCause,
+ VerifyUptime,
+)
+from tests.lib.utils import generate_test_ids_list
+from tests.units.test_models import FakeTestWithInput
+
+# Test classes used as expected values
+
+DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data"
+
+INIT_CATALOG_DATA: list[dict[str, Any]] = [
+ {
+ "name": "test_catalog",
+ "filename": "test_catalog.yml",
+ "tests": [
+ (VerifyEOSVersion, VerifyEOSVersion.Input(versions=["4.31.1F"])),
+ ],
+ },
+ {
+ "name": "test_catalog_with_tags",
+ "filename": "test_catalog_with_tags.yml",
+ "tests": [
+ (
+ VerifyUptime,
+ VerifyUptime.Input(
+ minimum=10,
+ filters=VerifyUptime.Input.Filters(tags=["fabric"]),
+ ),
+ ),
+ (VerifyReloadCause, {"filters": {"tags": ["leaf", "spine"]}}),
+ (VerifyCoredump, VerifyCoredump.Input()),
+ (VerifyAgentLogs, AntaTest.Input()),
+ (VerifyCPUUtilization, VerifyCPUUtilization.Input(filters=VerifyCPUUtilization.Input.Filters(tags=["leaf"]))),
+ (VerifyMemoryUtilization, VerifyMemoryUtilization.Input(filters=VerifyMemoryUtilization.Input.Filters(tags=["testdevice"]))),
+ (VerifyFileSystemUtilization, None),
+ (VerifyNTP, {}),
+ (VerifyMlagStatus, None),
+ (VerifyL3MTU, {"mtu": 1500, "filters": {"tags": ["demo"]}}),
+ ],
+ },
+ {
+ "name": "test_empty_catalog",
+ "filename": "test_empty_catalog.yml",
+ "tests": [],
+ },
+]
+CATALOG_PARSE_FAIL_DATA: list[dict[str, Any]] = [
+ {
+ "name": "undefined_tests",
+ "filename": "test_catalog_with_undefined_tests.yml",
+ "error": "FakeTest is not defined in Python module anta.tests.software",
+ },
+ {
+ "name": "undefined_module",
+ "filename": "test_catalog_with_undefined_module.yml",
+ "error": "Module named anta.tests.undefined cannot be imported",
+ },
+ {
+ "name": "undefined_module",
+ "filename": "test_catalog_with_undefined_module.yml",
+ "error": "Module named anta.tests.undefined cannot be imported",
+ },
+ {
+ "name": "syntax_error",
+ "filename": "test_catalog_with_syntax_error_module.yml",
+ "error": "Value error, Module named tests.data.syntax_error cannot be imported. Verify that the module exists and there is no Python syntax issues.",
+ },
+ {
+ "name": "undefined_module_nested",
+ "filename": "test_catalog_with_undefined_module_nested.yml",
+ "error": "Module named undefined from package anta.tests cannot be imported",
+ },
+ {
+ "name": "not_a_list",
+ "filename": "test_catalog_not_a_list.yml",
+ "error": "Value error, Syntax error when parsing: True\nIt must be a list of ANTA tests. Check the test catalog.",
+ },
+ {
+ "name": "test_definition_not_a_dict",
+ "filename": "test_catalog_test_definition_not_a_dict.yml",
+ "error": "Value error, Syntax error when parsing: VerifyEOSVersion\nIt must be a dictionary. Check the test catalog.",
+ },
+ {
+ "name": "test_definition_multiple_dicts",
+ "filename": "test_catalog_test_definition_multiple_dicts.yml",
+ "error": "Value error, Syntax error when parsing: {'VerifyEOSVersion': {'versions': ['4.25.4M', '4.26.1F']}, "
+ "'VerifyTerminAttrVersion': {'versions': ['4.25.4M']}}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog.",
+ },
+ {"name": "wrong_type_after_parsing", "filename": "test_catalog_wrong_type.yml", "error": "must be a dict, got str"},
+]
+CATALOG_FROM_DICT_FAIL_DATA: list[dict[str, Any]] = [
+ {
+ "name": "undefined_tests",
+ "filename": "test_catalog_with_undefined_tests.yml",
+ "error": "FakeTest is not defined in Python module anta.tests.software",
+ },
+ {
+ "name": "wrong_type",
+ "filename": "test_catalog_wrong_type.yml",
+ "error": "Wrong input type for catalog data, must be a dict, got str",
+ },
+]
+CATALOG_FROM_LIST_FAIL_DATA: list[dict[str, Any]] = [
+ {
+ "name": "wrong_inputs",
+ "tests": [
+ (
+ FakeTestWithInput,
+ AntaTest.Input(),
+ ),
+ ],
+ "error": "Test input has type AntaTest.Input but expected type FakeTestWithInput.Input",
+ },
+ {
+ "name": "no_test",
+ "tests": [(None, None)],
+ "error": "Input should be a subclass of AntaTest",
+ },
+ {
+ "name": "no_input_when_required",
+ "tests": [(FakeTestWithInput, None)],
+ "error": "Field required",
+ },
+ {
+ "name": "wrong_input_type",
+ "tests": [(FakeTestWithInput, True)],
+ "error": "Value error, Coud not instantiate inputs as type bool is not valid",
+ },
+]
+
+TESTS_SETTER_FAIL_DATA: list[dict[str, Any]] = [
+ {
+ "name": "not_a_list",
+ "tests": "not_a_list",
+ "error": "The catalog must contain a list of tests",
+ },
+ {
+ "name": "not_a_list_of_test_definitions",
+ "tests": [42, 43],
+ "error": "A test in the catalog must be an AntaTestDefinition instance",
+ },
+]
+
+
+class Test_AntaCatalog:
+ """
+ Test for anta.catalog.AntaCatalog
+ """
+
+ @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA))
+ def test_parse(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Instantiate AntaCatalog from a file
+ """
+ catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"]))
+
+ assert len(catalog.tests) == len(catalog_data["tests"])
+ for test_id, (test, inputs) in enumerate(catalog_data["tests"]):
+ assert catalog.tests[test_id].test == test
+ if inputs is not None:
+ if isinstance(inputs, dict):
+ inputs = test.Input(**inputs)
+ assert inputs == catalog.tests[test_id].inputs
+
+ @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA))
+ def test_from_list(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Instantiate AntaCatalog from a list
+ """
+ catalog: AntaCatalog = AntaCatalog.from_list(catalog_data["tests"])
+
+ assert len(catalog.tests) == len(catalog_data["tests"])
+ for test_id, (test, inputs) in enumerate(catalog_data["tests"]):
+ assert catalog.tests[test_id].test == test
+ if inputs is not None:
+ if isinstance(inputs, dict):
+ inputs = test.Input(**inputs)
+ assert inputs == catalog.tests[test_id].inputs
+
+ @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA))
+ def test_from_dict(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Instantiate AntaCatalog from a dict
+ """
+ with open(file=str(DATA_DIR / catalog_data["filename"]), mode="r", encoding="UTF-8") as file:
+ data = safe_load(file)
+ catalog: AntaCatalog = AntaCatalog.from_dict(data)
+
+ assert len(catalog.tests) == len(catalog_data["tests"])
+ for test_id, (test, inputs) in enumerate(catalog_data["tests"]):
+ assert catalog.tests[test_id].test == test
+ if inputs is not None:
+ if isinstance(inputs, dict):
+ inputs = test.Input(**inputs)
+ assert inputs == catalog.tests[test_id].inputs
+
+ @pytest.mark.parametrize("catalog_data", CATALOG_PARSE_FAIL_DATA, ids=generate_test_ids_list(CATALOG_PARSE_FAIL_DATA))
+ def test_parse_fail(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Errors when instantiating AntaCatalog from a file
+ """
+ with pytest.raises((ValidationError, ValueError)) as exec_info:
+ AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"]))
+ if isinstance(exec_info.value, ValidationError):
+ assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
+ else:
+ assert catalog_data["error"] in str(exec_info)
+
+ def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None:
+ """
+ Errors when instantiating AntaCatalog from a file
+ """
+ with pytest.raises(Exception) as exec_info:
+ AntaCatalog.parse(str(DATA_DIR / "catalog_does_not_exist.yml"))
+ assert "No such file or directory" in str(exec_info)
+ assert len(caplog.record_tuples) >= 1
+ _, _, message = caplog.record_tuples[0]
+ assert "Unable to parse ANTA Test Catalog file" in message
+ assert "FileNotFoundError ([Errno 2] No such file or directory" in message
+
+ @pytest.mark.parametrize("catalog_data", CATALOG_FROM_LIST_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_LIST_FAIL_DATA))
+ def test_from_list_fail(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Errors when instantiating AntaCatalog from a list of tuples
+ """
+ with pytest.raises(ValidationError) as exec_info:
+ AntaCatalog.from_list(catalog_data["tests"])
+ assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
+
+ @pytest.mark.parametrize("catalog_data", CATALOG_FROM_DICT_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_DICT_FAIL_DATA))
+ def test_from_dict_fail(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Errors when instantiating AntaCatalog from a list of tuples
+ """
+ with open(file=str(DATA_DIR / catalog_data["filename"]), mode="r", encoding="UTF-8") as file:
+ data = safe_load(file)
+ with pytest.raises((ValidationError, ValueError)) as exec_info:
+ AntaCatalog.from_dict(data)
+ if isinstance(exec_info.value, ValidationError):
+ assert catalog_data["error"] in exec_info.value.errors()[0]["msg"]
+ else:
+ assert catalog_data["error"] in str(exec_info)
+
+ def test_filename(self) -> None:
+ """
+ Test filename
+ """
+ catalog = AntaCatalog(filename="test")
+ assert catalog.filename == Path("test")
+ catalog = AntaCatalog(filename=Path("test"))
+ assert catalog.filename == Path("test")
+
+ @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA))
+ def test__tests_setter_success(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Success when setting AntaCatalog.tests from a list of tuples
+ """
+ catalog = AntaCatalog()
+ catalog.tests = [AntaTestDefinition(test=test, inputs=inputs) for test, inputs in catalog_data["tests"]]
+ assert len(catalog.tests) == len(catalog_data["tests"])
+ for test_id, (test, inputs) in enumerate(catalog_data["tests"]):
+ assert catalog.tests[test_id].test == test
+ if inputs is not None:
+ if isinstance(inputs, dict):
+ inputs = test.Input(**inputs)
+ assert inputs == catalog.tests[test_id].inputs
+
+ @pytest.mark.parametrize("catalog_data", TESTS_SETTER_FAIL_DATA, ids=generate_test_ids_list(TESTS_SETTER_FAIL_DATA))
+ def test__tests_setter_fail(self, catalog_data: dict[str, Any]) -> None:
+ """
+ Errors when setting AntaCatalog.tests from a list of tuples
+ """
+ catalog = AntaCatalog()
+ with pytest.raises(ValueError) as exec_info:
+ catalog.tests = catalog_data["tests"]
+ assert catalog_data["error"] in str(exec_info)
+
+ def test_get_tests_by_tags(self) -> None:
+ """
+ Test AntaCatalog.test_get_tests_by_tags()
+ """
+ catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml"))
+ tests: list[AntaTestDefinition] = catalog.get_tests_by_tags(tags=["leaf"])
+ assert len(tests) == 2
diff --git a/tests/units/test_device.py b/tests/units/test_device.py
new file mode 100644
index 0000000..845da2b
--- /dev/null
+++ b/tests/units/test_device.py
@@ -0,0 +1,777 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+test anta.device.py
+"""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import httpx
+import pytest
+from _pytest.mark.structures import ParameterSet
+from asyncssh import SSHClientConnection, SSHClientConnectionOptions
+from rich import print as rprint
+
+from anta import aioeapi
+from anta.device import AntaDevice, AsyncEOSDevice
+from anta.models import AntaCommand
+from tests.lib.fixture import COMMAND_OUTPUT
+from tests.lib.utils import generate_test_ids_list
+
+INIT_DATA: list[dict[str, Any]] = [
+ {
+ "name": "no name, no port",
+ "device": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ },
+ "expected": {"name": "42.42.42.42"},
+ },
+ {
+ "name": "no name, port",
+ "device": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ "port": 666,
+ },
+ "expected": {"name": "42.42.42.42:666"},
+ },
+ {
+ "name": "name",
+ "device": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ "name": "test.anta.ninja",
+ "disable_cache": True,
+ },
+ "expected": {"name": "test.anta.ninja"},
+ },
+ {
+ "name": "insecure",
+ "device": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ "name": "test.anta.ninja",
+ "insecure": True,
+ },
+ "expected": {"name": "test.anta.ninja"},
+ },
+]
+EQUALITY_DATA: list[dict[str, Any]] = [
+ {
+ "name": "equal",
+ "device1": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ },
+ "device2": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "blah",
+ },
+ "expected": True,
+ },
+ {
+ "name": "equals-name",
+ "device1": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ "name": "device1",
+ },
+ "device2": {
+ "host": "42.42.42.42",
+ "username": "plop",
+ "password": "anta",
+ "name": "device2",
+ },
+ "expected": True,
+ },
+ {
+ "name": "not-equal-port",
+ "device1": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ },
+ "device2": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ "port": 666,
+ },
+ "expected": False,
+ },
+ {
+ "name": "not-equal-host",
+ "device1": {
+ "host": "42.42.42.41",
+ "username": "anta",
+ "password": "anta",
+ },
+ "device2": {
+ "host": "42.42.42.42",
+ "username": "anta",
+ "password": "anta",
+ },
+ "expected": False,
+ },
+]
+AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [
+ {
+ "name": "command",
+ "device": {},
+ "command": {
+ "command": "show version",
+ "patch_kwargs": {
+ "return_value": [
+ {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ }
+ ]
+ },
+ },
+ "expected": {
+ "output": {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ "errors": [],
+ },
+ },
+ {
+ "name": "enable",
+ "device": {"enable": True},
+ "command": {
+ "command": "show version",
+ "patch_kwargs": {
+ "return_value": [
+ {},
+ {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ ]
+ },
+ },
+ "expected": {
+ "output": {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ "errors": [],
+ },
+ },
+ {
+ "name": "enable password",
+ "device": {"enable": True, "enable_password": "anta"},
+ "command": {
+ "command": "show version",
+ "patch_kwargs": {
+ "return_value": [
+ {},
+ {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ ]
+ },
+ },
+ "expected": {
+ "output": {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ "errors": [],
+ },
+ },
+ {
+ "name": "revision",
+ "device": {},
+ "command": {
+ "command": "show version",
+ "revision": 3,
+ "patch_kwargs": {
+ "return_value": [
+ {},
+ {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ ]
+ },
+ },
+ "expected": {
+ "output": {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ },
+ "errors": [],
+ },
+ },
+ {
+ "name": "aioeapi.EapiCommandError",
+ "device": {},
+ "command": {
+ "command": "show version",
+ "patch_kwargs": {
+ "side_effect": aioeapi.EapiCommandError(
+ passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[]
+ )
+ },
+ },
+ "expected": {"output": None, "errors": ["Authorization denied for command 'show version'"]},
+ },
+ {
+ "name": "httpx.HTTPError",
+ "device": {},
+ "command": {
+ "command": "show version",
+ "patch_kwargs": {"side_effect": httpx.HTTPError(message="404")},
+ },
+ "expected": {"output": None, "errors": ["404"]},
+ },
+ {
+ "name": "httpx.ConnectError",
+ "device": {},
+ "command": {
+ "command": "show version",
+ "patch_kwargs": {"side_effect": httpx.ConnectError(message="Cannot open port")},
+ },
+ "expected": {"output": None, "errors": ["Cannot open port"]},
+ },
+]
+AIOEAPI_COPY_DATA: list[dict[str, Any]] = [
+ {
+ "name": "from",
+ "device": {},
+ "copy": {
+ "sources": [Path("/mnt/flash"), Path("/var/log/agents")],
+ "destination": Path("."),
+ "direction": "from",
+ },
+ },
+ {
+ "name": "to",
+ "device": {},
+ "copy": {
+ "sources": [Path("/mnt/flash"), Path("/var/log/agents")],
+ "destination": Path("."),
+ "direction": "to",
+ },
+ },
+ {
+ "name": "wrong",
+ "device": {},
+ "copy": {
+ "sources": [Path("/mnt/flash"), Path("/var/log/agents")],
+ "destination": Path("."),
+ "direction": "wrong",
+ },
+ },
+]
+REFRESH_DATA: list[dict[str, Any]] = [
+ {
+ "name": "established",
+ "device": {},
+ "patch_kwargs": (
+ {"return_value": True},
+ {
+ "return_value": {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ }
+ },
+ ),
+ "expected": {"is_online": True, "established": True, "hw_model": "DCS-7280CR3-32P4-F"},
+ },
+ {
+ "name": "is not online",
+ "device": {},
+ "patch_kwargs": (
+ {"return_value": False},
+ {
+ "return_value": {
+ "mfgName": "Arista",
+ "modelName": "DCS-7280CR3-32P4-F",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ }
+ },
+ ),
+ "expected": {"is_online": False, "established": False, "hw_model": None},
+ },
+ {
+ "name": "cannot parse command",
+ "device": {},
+ "patch_kwargs": (
+ {"return_value": True},
+ {
+ "return_value": {
+ "mfgName": "Arista",
+ "hardwareRevision": "11.00",
+ "serialNumber": "JPE19500066",
+ "systemMacAddress": "fc:bd:67:3d:13:c5",
+ "hwMacAddress": "fc:bd:67:3d:13:c5",
+ "configMacAddress": "00:00:00:00:00:00",
+ "version": "4.31.1F-34361447.fraserrel (engineering build)",
+ "architecture": "x86_64",
+ "internalVersion": "4.31.1F-34361447.fraserrel",
+ "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c",
+ "imageFormatVersion": "3.0",
+ "imageOptimization": "Default",
+ "bootupTimestamp": 1700729434.5892005,
+ "uptime": 20666.78,
+ "memTotal": 8099732,
+ "memFree": 4989568,
+ "isIntlVersion": False,
+ }
+ },
+ ),
+ "expected": {"is_online": True, "established": False, "hw_model": None},
+ },
+ {
+ "name": "aioeapi.EapiCommandError",
+ "device": {},
+ "patch_kwargs": (
+ {"return_value": True},
+ {
+ "side_effect": aioeapi.EapiCommandError(
+ passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[]
+ )
+ },
+ ),
+ "expected": {"is_online": True, "established": False, "hw_model": None},
+ },
+ {
+ "name": "httpx.HTTPError",
+ "device": {},
+ "patch_kwargs": (
+ {"return_value": True},
+ {"side_effect": httpx.HTTPError(message="404")},
+ ),
+ "expected": {"is_online": True, "established": False, "hw_model": None},
+ },
+ {
+ "name": "httpx.ConnectError",
+ "device": {},
+ "patch_kwargs": (
+ {"return_value": True},
+ {"side_effect": httpx.ConnectError(message="Cannot open port")},
+ ),
+ "expected": {"is_online": True, "established": False, "hw_model": None},
+ },
+]
+COLLECT_DATA: list[dict[str, Any]] = [
+ {
+ "name": "device cache enabled, command cache enabled, no cache hit",
+ "device": {"disable_cache": False},
+ "command": {
+ "command": "show version",
+ "use_cache": True,
+ },
+ "expected": {"cache_hit": False},
+ },
+ {
+ "name": "device cache enabled, command cache enabled, cache hit",
+ "device": {"disable_cache": False},
+ "command": {
+ "command": "show version",
+ "use_cache": True,
+ },
+ "expected": {"cache_hit": True},
+ },
+ {
+ "name": "device cache disabled, command cache enabled",
+ "device": {"disable_cache": True},
+ "command": {
+ "command": "show version",
+ "use_cache": True,
+ },
+ "expected": {},
+ },
+ {
+ "name": "device cache enabled, command cache disabled, cache has command",
+ "device": {"disable_cache": False},
+ "command": {
+ "command": "show version",
+ "use_cache": False,
+ },
+ "expected": {"cache_hit": True},
+ },
+ {
+ "name": "device cache enabled, command cache disabled, cache does not have data",
+ "device": {
+ "disable_cache": False,
+ },
+ "command": {
+ "command": "show version",
+ "use_cache": False,
+ },
+ "expected": {"cache_hit": False},
+ },
+ {
+ "name": "device cache disabled, command cache disabled",
+ "device": {
+ "disable_cache": True,
+ },
+ "command": {
+ "command": "show version",
+ "use_cache": False,
+ },
+ "expected": {},
+ },
+]
+CACHE_STATS_DATA: list[ParameterSet] = [
+ pytest.param({"disable_cache": False}, {"total_commands_sent": 0, "cache_hits": 0, "cache_hit_ratio": "0.00%"}, id="with_cache"),
+ pytest.param({"disable_cache": True}, None, id="without_cache"),
+]
+
+
+class TestAntaDevice:
+ """
+ Test for anta.device.AntaDevice Abstract class
+ """
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "device, command_data, expected_data",
+ map(lambda d: (d["device"], d["command"], d["expected"]), COLLECT_DATA),
+ indirect=["device"],
+ ids=generate_test_ids_list(COLLECT_DATA),
+ )
+ async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], expected_data: dict[str, Any]) -> None:
+ """
+ Test AntaDevice.collect behavior
+ """
+ command = AntaCommand(command=command_data["command"], use_cache=command_data["use_cache"])
+
+ # Dummy output for cache hit
+ cached_output = "cached_value"
+
+ if device.cache is not None and expected_data["cache_hit"] is True:
+ await device.cache.set(command.uid, cached_output)
+
+ await device.collect(command)
+
+ if device.cache is not None: # device_cache is enabled
+ current_cached_data = await device.cache.get(command.uid)
+ if command.use_cache is True: # command is allowed to use cache
+ if expected_data["cache_hit"] is True:
+ assert command.output == cached_output
+ assert current_cached_data == cached_output
+ assert device.cache.hit_miss_ratio["hits"] == 2
+ else:
+ assert command.output == COMMAND_OUTPUT
+ assert current_cached_data == COMMAND_OUTPUT
+ assert device.cache.hit_miss_ratio["hits"] == 1
+ else: # command is not allowed to use cache
+ device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access
+ assert command.output == COMMAND_OUTPUT
+ if expected_data["cache_hit"] is True:
+ assert current_cached_data == cached_output
+ else:
+ assert current_cached_data is None
+ else: # device is disabled
+ assert device.cache is None
+ device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access
+
+ @pytest.mark.parametrize("device, expected", CACHE_STATS_DATA, indirect=["device"])
+ def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None:
+ """
+ Verify that when cache statistics attribute does not exist
+ TODO add a test where cache has some value
+ """
+ assert device.cache_statistics == expected
+
+ def test_supports(self, device: AntaDevice) -> None:
+ """
+ Test if the supports() method
+ """
+ command = AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"])
+ assert device.supports(command) is False
+ command = AntaCommand(command="show hardware counter drop")
+ assert device.supports(command) is True
+
+
+class TestAsyncEOSDevice:
+ """
+ Test for anta.device.AsyncEOSDevice
+ """
+
+ @pytest.mark.parametrize("data", INIT_DATA, ids=generate_test_ids_list(INIT_DATA))
+ def test__init__(self, data: dict[str, Any]) -> None:
+ """Test the AsyncEOSDevice constructor"""
+ device = AsyncEOSDevice(**data["device"])
+
+ assert device.name == data["expected"]["name"]
+ if data["device"].get("disable_cache") is True:
+ assert device.cache is None
+ assert device.cache_locks is None
+ else: # False or None
+ assert device.cache is not None
+ assert device.cache_locks is not None
+ hash(device)
+
+ with patch("anta.device.__DEBUG__", True):
+ rprint(device)
+
+ @pytest.mark.parametrize("data", EQUALITY_DATA, ids=generate_test_ids_list(EQUALITY_DATA))
+ def test__eq(self, data: dict[str, Any]) -> None:
+ """Test the AsyncEOSDevice equality"""
+ device1 = AsyncEOSDevice(**data["device1"])
+ device2 = AsyncEOSDevice(**data["device2"])
+ if data["expected"]:
+ assert device1 == device2
+ else:
+ assert device1 != device2
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "async_device, patch_kwargs, expected",
+ map(lambda d: (d["device"], d["patch_kwargs"], d["expected"]), REFRESH_DATA),
+ ids=generate_test_ids_list(REFRESH_DATA),
+ indirect=["async_device"],
+ )
+ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[dict[str, Any]], expected: dict[str, Any]) -> None:
+ # pylint: disable=protected-access
+ """Test AsyncEOSDevice.refresh()"""
+ with patch.object(async_device._session, "check_connection", **patch_kwargs[0]):
+ with patch.object(async_device._session, "cli", **patch_kwargs[1]):
+ await async_device.refresh()
+ async_device._session.check_connection.assert_called_once()
+ if expected["is_online"]:
+ async_device._session.cli.assert_called_once()
+ assert async_device.is_online == expected["is_online"]
+ assert async_device.established == expected["established"]
+ assert async_device.hw_model == expected["hw_model"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "async_device, command, expected",
+ map(lambda d: (d["device"], d["command"], d["expected"]), AIOEAPI_COLLECT_DATA),
+ ids=generate_test_ids_list(AIOEAPI_COLLECT_DATA),
+ indirect=["async_device"],
+ )
+ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None:
+ # pylint: disable=protected-access
+ """Test AsyncEOSDevice._collect()"""
+ if "revision" in command:
+ cmd = AntaCommand(command=command["command"], revision=command["revision"])
+ else:
+ cmd = AntaCommand(command=command["command"])
+ with patch.object(async_device._session, "cli", **command["patch_kwargs"]):
+ await async_device.collect(cmd)
+ commands = []
+ if async_device.enable and async_device._enable_password is not None:
+ commands.append(
+ {
+ "cmd": "enable",
+ "input": str(async_device._enable_password),
+ }
+ )
+ elif async_device.enable:
+ # No password
+ commands.append({"cmd": "enable"})
+ if cmd.revision:
+ commands.append({"cmd": cmd.command, "revision": cmd.revision})
+ else:
+ commands.append({"cmd": cmd.command})
+ async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version)
+ assert cmd.output == expected["output"]
+ assert cmd.errors == expected["errors"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "async_device, copy",
+ map(lambda d: (d["device"], d["copy"]), AIOEAPI_COPY_DATA),
+ ids=generate_test_ids_list(AIOEAPI_COPY_DATA),
+ indirect=["async_device"],
+ )
+ async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None:
+ """Test AsyncEOSDevice.copy()"""
+ conn = SSHClientConnection(asyncio.get_event_loop(), SSHClientConnectionOptions())
+ with patch("asyncssh.connect") as connect_mock:
+ connect_mock.return_value.__aenter__.return_value = conn
+ with patch("asyncssh.scp") as scp_mock:
+ await async_device.copy(copy["sources"], copy["destination"], copy["direction"])
+ if copy["direction"] == "from":
+ src = [(conn, file) for file in copy["sources"]]
+ dst = copy["destination"]
+ elif copy["direction"] == "to":
+ src = copy["sources"]
+ dst = conn, copy["destination"]
+ else:
+ scp_mock.assert_not_awaited()
+ return
+ scp_mock.assert_awaited_once_with(src, dst)
diff --git a/tests/units/test_logger.py b/tests/units/test_logger.py
new file mode 100644
index 0000000..6e1e5b4
--- /dev/null
+++ b/tests/units/test_logger.py
@@ -0,0 +1,80 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.logger
+"""
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from anta.logger import anta_log_exception
+
+if TYPE_CHECKING:
+ from pytest import LogCaptureFixture
+
+
+@pytest.mark.parametrize(
+ "exception, message, calling_logger, __DEBUG__value, expected_message",
+ [
+ pytest.param(ValueError("exception message"), None, None, False, "ValueError (exception message)", id="exception only"),
+ pytest.param(ValueError("exception message"), "custom message", None, False, "custom message\nValueError (exception message)", id="custom message"),
+ pytest.param(
+ ValueError("exception message"),
+ "custom logger",
+ logging.getLogger("custom"),
+ False,
+ "custom logger\nValueError (exception message)",
+ id="custom logger",
+ ),
+ pytest.param(
+ ValueError("exception message"), "Use with custom message", None, True, "Use with custom message\nValueError (exception message)", id="__DEBUG__ on"
+ ),
+ ],
+)
+def test_anta_log_exception(
+ caplog: LogCaptureFixture,
+ exception: Exception,
+ message: str | None,
+ calling_logger: logging.Logger | None,
+ __DEBUG__value: bool,
+ expected_message: str,
+) -> None:
+ """
+ Test anta_log_exception
+ """
+
+ if calling_logger is not None:
+ # https://github.com/pytest-dev/pytest/issues/3697
+ calling_logger.propagate = True
+ caplog.set_level(logging.ERROR, logger=calling_logger.name)
+ else:
+ caplog.set_level(logging.ERROR)
+ # Need to raise to trigger nice stacktrace for __DEBUG__ == True
+ try:
+ raise exception
+ except ValueError as e:
+ with patch("anta.logger.__DEBUG__", __DEBUG__value):
+ anta_log_exception(e, message=message, calling_logger=calling_logger)
+
+ # Two log captured
+ if __DEBUG__value:
+ assert len(caplog.record_tuples) == 2
+ else:
+ assert len(caplog.record_tuples) == 1
+ logger, level, message = caplog.record_tuples[0]
+
+ if calling_logger is not None:
+ assert calling_logger.name == logger
+ else:
+ assert logger == "anta.logger"
+
+ assert level == logging.CRITICAL
+ assert message == expected_message
+ # the only place where we can see the stracktrace is in the capture.text
+ if __DEBUG__value is True:
+ assert "Traceback" in caplog.text
diff --git a/tests/units/test_models.py b/tests/units/test_models.py
new file mode 100644
index 0000000..c0585a4
--- /dev/null
+++ b/tests/units/test_models.py
@@ -0,0 +1,472 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+test anta.models.py
+"""
+# Mypy does not understand AntaTest.Input typing
+# mypy: disable-error-code=attr-defined
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+import pytest
+
+from anta.decorators import deprecated_test, skip_on_platforms
+from anta.device import AntaDevice
+from anta.models import AntaCommand, AntaTemplate, AntaTest
+from tests.lib.fixture import DEVICE_HW_MODEL
+from tests.lib.utils import generate_test_ids
+
+
+class FakeTest(AntaTest):
+ """ANTA test that always succeed"""
+
+ name = "FakeTest"
+ description = "ANTA test that always succeed"
+ categories = []
+ commands = []
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+class FakeTestWithFailedCommand(AntaTest):
+ """ANTA test with a command that failed"""
+
+ name = "FakeTestWithFailedCommand"
+ description = "ANTA test with a command that failed"
+ categories = []
+ commands = [AntaCommand(command="show version", errors=["failed command"])]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+class FakeTestWithUnsupportedCommand(AntaTest):
+ """ANTA test with an unsupported command"""
+
+ name = "FakeTestWithUnsupportedCommand"
+ description = "ANTA test with an unsupported command"
+ categories = []
+ commands = [AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"])]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+class FakeTestWithInput(AntaTest):
+ """ANTA test with inputs that always succeed"""
+
+ name = "FakeTestWithInput"
+ description = "ANTA test with inputs that always succeed"
+ categories = []
+ commands = []
+
+ class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
+ string: str
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success(self.inputs.string)
+
+
+class FakeTestWithTemplate(AntaTest):
+ """ANTA test with template that always succeed"""
+
+ name = "FakeTestWithTemplate"
+ description = "ANTA test with template that always succeed"
+ categories = []
+ commands = [AntaTemplate(template="show interface {interface}")]
+
+ class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
+ interface: str
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ return [template.render(interface=self.inputs.interface)]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success(self.instance_commands[0].command)
+
+
+class FakeTestWithTemplateNoRender(AntaTest):
+ """ANTA test with template that miss the render() method"""
+
+ name = "FakeTestWithTemplateNoRender"
+ description = "ANTA test with template that miss the render() method"
+ categories = []
+ commands = [AntaTemplate(template="show interface {interface}")]
+
+ class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
+ interface: str
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success(self.instance_commands[0].command)
+
+
+class FakeTestWithTemplateBadRender1(AntaTest):
+ """ANTA test with template that raises a AntaTemplateRenderError exception"""
+
+ name = "FakeTestWithTemplateBadRender"
+ description = "ANTA test with template that raises a AntaTemplateRenderError exception"
+ categories = []
+ commands = [AntaTemplate(template="show interface {interface}")]
+
+ class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
+ interface: str
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ return [template.render(wrong_template_param=self.inputs.interface)]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success(self.instance_commands[0].command)
+
+
+class FakeTestWithTemplateBadRender2(AntaTest):
+ """ANTA test with template that raises an arbitrary exception"""
+
+ name = "FakeTestWithTemplateBadRender2"
+ description = "ANTA test with template that raises an arbitrary exception"
+ categories = []
+ commands = [AntaTemplate(template="show interface {interface}")]
+
+ class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
+ interface: str
+
+ def render(self, template: AntaTemplate) -> list[AntaCommand]:
+ raise Exception() # pylint: disable=broad-exception-raised
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success(self.instance_commands[0].command)
+
+
+class SkipOnPlatformTest(AntaTest):
+ """ANTA test that is skipped"""
+
+ name = "SkipOnPlatformTest"
+ description = "ANTA test that is skipped on a specific platform"
+ categories = []
+ commands = []
+
+ @skip_on_platforms([DEVICE_HW_MODEL])
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+class UnSkipOnPlatformTest(AntaTest):
+ """ANTA test that is skipped"""
+
+ name = "UnSkipOnPlatformTest"
+ description = "ANTA test that is skipped on a specific platform"
+ categories = []
+ commands = []
+
+ @skip_on_platforms(["dummy"])
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+class SkipOnPlatformTestWithInput(AntaTest):
+ """ANTA test skipped on platforms but with Input"""
+
+ name = "SkipOnPlatformTestWithInput"
+ description = "ANTA test skipped on platforms but with Input"
+ categories = []
+ commands = []
+
+ class Input(AntaTest.Input): # pylint: disable=missing-class-docstring
+ string: str
+
+ @skip_on_platforms([DEVICE_HW_MODEL])
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success(self.inputs.string)
+
+
+class DeprecatedTestWithoutNewTest(AntaTest):
+ """ANTA test that is deprecated without new test"""
+
+ name = "DeprecatedTestWitouthNewTest"
+ description = "ANTA test that is deprecated without new test"
+ categories = []
+ commands = []
+
+ @deprecated_test()
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+class DeprecatedTestWithNewTest(AntaTest):
+ """ANTA test that is deprecated with new test."""
+
+ name = "DeprecatedTestWithNewTest"
+ description = "ANTA deprecated test with New Test"
+ categories = []
+ commands = []
+
+ @deprecated_test(new_tests=["NewTest"])
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+
+ANTATEST_DATA: list[dict[str, Any]] = [
+ {"name": "no input", "test": FakeTest, "inputs": None, "expected": {"__init__": {"result": "unset"}, "test": {"result": "success"}}},
+ {
+ "name": "extra input",
+ "test": FakeTest,
+ "inputs": {"string": "culpa! veniam quas quas veniam molestias, esse"},
+ "expected": {"__init__": {"result": "error", "messages": ["Extra inputs are not permitted"]}, "test": {"result": "error"}},
+ },
+ {
+ "name": "no input",
+ "test": FakeTestWithInput,
+ "inputs": None,
+ "expected": {"__init__": {"result": "error", "messages": ["Field required"]}, "test": {"result": "error"}},
+ },
+ {
+ "name": "wrong input type",
+ "test": FakeTestWithInput,
+ "inputs": {"string": 1},
+ "expected": {"__init__": {"result": "error", "messages": ["Input should be a valid string"]}, "test": {"result": "error"}},
+ },
+ {
+ "name": "good input",
+ "test": FakeTestWithInput,
+ "inputs": {"string": "culpa! veniam quas quas veniam molestias, esse"},
+ "expected": {"__init__": {"result": "unset"}, "test": {"result": "success", "messages": ["culpa! veniam quas quas veniam molestias, esse"]}},
+ },
+ {
+ "name": "good input",
+ "test": FakeTestWithTemplate,
+ "inputs": {"interface": "Ethernet1"},
+ "expected": {"__init__": {"result": "unset"}, "test": {"result": "success", "messages": ["show interface Ethernet1"]}},
+ },
+ {
+ "name": "wrong input type",
+ "test": FakeTestWithTemplate,
+ "inputs": {"interface": 1},
+ "expected": {"__init__": {"result": "error", "messages": ["Input should be a valid string"]}, "test": {"result": "error"}},
+ },
+ {
+ "name": "wrong render definition",
+ "test": FakeTestWithTemplateNoRender,
+ "inputs": {"interface": "Ethernet1"},
+ "expected": {
+ "__init__": {
+ "result": "error",
+ "messages": ["AntaTemplate are provided but render() method has not been implemented for tests.units.test_models.FakeTestWithTemplateNoRender"],
+ },
+ "test": {"result": "error"},
+ },
+ },
+ {
+ "name": "AntaTemplateRenderError",
+ "test": FakeTestWithTemplateBadRender1,
+ "inputs": {"interface": "Ethernet1"},
+ "expected": {
+ "__init__": {
+ "result": "error",
+ "messages": ["Cannot render template {template='show interface {interface}' version='latest' revision=None ofmt='json' use_cache=True}"],
+ },
+ "test": {"result": "error"},
+ },
+ },
+ {
+ "name": "Exception in render()",
+ "test": FakeTestWithTemplateBadRender2,
+ "inputs": {"interface": "Ethernet1"},
+ "expected": {
+ "__init__": {
+ "result": "error",
+ "messages": ["Exception in tests.units.test_models.FakeTestWithTemplateBadRender2.render(): Exception"],
+ },
+ "test": {"result": "error"},
+ },
+ },
+ {
+ "name": "unskip on platforms",
+ "test": UnSkipOnPlatformTest,
+ "inputs": None,
+ "expected": {
+ "__init__": {"result": "unset"},
+ "test": {"result": "success"},
+ },
+ },
+ {
+ "name": "skip on platforms, unset",
+ "test": SkipOnPlatformTest,
+ "inputs": None,
+ "expected": {
+ "__init__": {"result": "unset"},
+ "test": {"result": "skipped"},
+ },
+ },
+ {
+ "name": "skip on platforms, not unset",
+ "test": SkipOnPlatformTestWithInput,
+ "inputs": None,
+ "expected": {"__init__": {"result": "error", "messages": ["Field required"]}, "test": {"result": "error"}},
+ },
+ {
+ "name": "deprecate test without new test",
+ "test": DeprecatedTestWithoutNewTest,
+ "inputs": None,
+ "expected": {
+ "__init__": {"result": "unset"},
+ "test": {"result": "success"},
+ },
+ },
+ {
+ "name": "deprecate test with new test",
+ "test": DeprecatedTestWithNewTest,
+ "inputs": None,
+ "expected": {
+ "__init__": {"result": "unset"},
+ "test": {"result": "success"},
+ },
+ },
+ {
+ "name": "failed command",
+ "test": FakeTestWithFailedCommand,
+ "inputs": None,
+ "expected": {"__init__": {"result": "unset"}, "test": {"result": "error", "messages": ["show version has failed: failed command"]}},
+ },
+ {
+ "name": "unsupported command",
+ "test": FakeTestWithUnsupportedCommand,
+ "inputs": None,
+ "expected": {
+ "__init__": {"result": "unset"},
+ "test": {"result": "skipped", "messages": ["Skipped because show hardware counter drop is not supported on pytest"]},
+ },
+ },
+]
+
+
+class Test_AntaTest:
+ """
+ Test for anta.models.AntaTest
+ """
+
+ def test__init_subclass__name(self) -> None:
+ """Test __init_subclass__"""
+ # Pylint detects all the classes in here as unused which is on purpose
+ # pylint: disable=unused-variable
+ with pytest.raises(NotImplementedError) as exec_info:
+
+ class WrongTestNoName(AntaTest):
+ """ANTA test that is missing a name"""
+
+ description = "ANTA test that is missing a name"
+ categories = []
+ commands = []
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+ assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoName is missing required class attribute name"
+
+ with pytest.raises(NotImplementedError) as exec_info:
+
+ class WrongTestNoDescription(AntaTest):
+ """ANTA test that is missing a description"""
+
+ name = "WrongTestNoDescription"
+ categories = []
+ commands = []
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+ assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoDescription is missing required class attribute description"
+
+ with pytest.raises(NotImplementedError) as exec_info:
+
+ class WrongTestNoCategories(AntaTest):
+ """ANTA test that is missing categories"""
+
+ name = "WrongTestNoCategories"
+ description = "ANTA test that is missing categories"
+ commands = []
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+ assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoCategories is missing required class attribute categories"
+
+ with pytest.raises(NotImplementedError) as exec_info:
+
+ class WrongTestNoCommands(AntaTest):
+ """ANTA test that is missing commands"""
+
+ name = "WrongTestNoCommands"
+ description = "ANTA test that is missing commands"
+ categories = []
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+ assert exec_info.value.args[0] == "Class tests.units.test_models.WrongTestNoCommands is missing required class attribute commands"
+
+ def _assert_test(self, test: AntaTest, expected: dict[str, Any]) -> None:
+ assert test.result.result == expected["result"]
+ if "messages" in expected:
+ for result_msg, expected_msg in zip(test.result.messages, expected["messages"]): # NOTE: zip(strict=True) has been added in Python 3.10
+ assert expected_msg in result_msg
+
+ @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA))
+ def test__init__(self, device: AntaDevice, data: dict[str, Any]) -> None:
+ """Test the AntaTest constructor"""
+ expected = data["expected"]["__init__"]
+ test = data["test"](device, inputs=data["inputs"])
+ self._assert_test(test, expected)
+
+ @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA))
+ def test_test(self, device: AntaDevice, data: dict[str, Any]) -> None:
+ """Test the AntaTest.test method"""
+ expected = data["expected"]["test"]
+ test = data["test"](device, inputs=data["inputs"])
+ asyncio.run(test.test())
+ self._assert_test(test, expected)
+
+
+ANTATEST_BLACKLIST_DATA = ["reload", "reload --force", "write", "wr mem"]
+
+
+@pytest.mark.parametrize("data", ANTATEST_BLACKLIST_DATA)
+def test_blacklist(device: AntaDevice, data: str) -> None:
+ """Test for blacklisting function."""
+
+ class FakeTestWithBlacklist(AntaTest):
+ """Fake Test for blacklist"""
+
+ name = "FakeTestWithBlacklist"
+ description = "ANTA test that has blacklisted command"
+ categories = []
+ commands = [AntaCommand(command=data)]
+
+ @AntaTest.anta_test
+ def test(self) -> None:
+ self.result.is_success()
+
+ test_instance = FakeTestWithBlacklist(device)
+
+ # Run the test() method
+ asyncio.run(test_instance.test())
+ assert test_instance.result.result == "error"
diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py
new file mode 100644
index 0000000..c353cbe
--- /dev/null
+++ b/tests/units/test_runner.py
@@ -0,0 +1,82 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+test anta.runner.py
+"""
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+import pytest
+
+from anta import logger
+from anta.catalog import AntaCatalog
+from anta.inventory import AntaInventory
+from anta.result_manager import ResultManager
+from anta.runner import main
+
+from .test_models import FakeTest
+
+if TYPE_CHECKING:
+ from pytest import LogCaptureFixture
+
+FAKE_CATALOG: AntaCatalog = AntaCatalog.from_list([(FakeTest, None)])
+
+
+@pytest.mark.asyncio
+async def test_runner_empty_tests(caplog: LogCaptureFixture, test_inventory: AntaInventory) -> None:
+ """
+ Test that when the list of tests is empty, a log is raised
+
+ caplog is the pytest fixture to capture logs
+ test_inventory is a fixture that gives a default inventory for tests
+ """
+ logger.setup_logging(logger.Log.INFO)
+ caplog.set_level(logging.INFO)
+ manager = ResultManager()
+ await main(manager, test_inventory, AntaCatalog())
+
+ assert len(caplog.record_tuples) == 1
+ assert "The list of tests is empty, exiting" in caplog.records[0].message
+
+
+@pytest.mark.asyncio
+async def test_runner_empty_inventory(caplog: LogCaptureFixture) -> None:
+ """
+ Test that when the Inventory is empty, a log is raised
+
+ caplog is the pytest fixture to capture logs
+ """
+ logger.setup_logging(logger.Log.INFO)
+ caplog.set_level(logging.INFO)
+ manager = ResultManager()
+ inventory = AntaInventory()
+ await main(manager, inventory, FAKE_CATALOG)
+ assert len(caplog.record_tuples) == 1
+ assert "The inventory is empty, exiting" in caplog.records[0].message
+
+
+@pytest.mark.asyncio
+async def test_runner_no_selected_device(caplog: LogCaptureFixture, test_inventory: AntaInventory) -> None:
+ """
+ Test that when the list of established device
+
+ caplog is the pytest fixture to capture logs
+ test_inventory is a fixture that gives a default inventory for tests
+ """
+ logger.setup_logging(logger.Log.INFO)
+ caplog.set_level(logging.INFO)
+ manager = ResultManager()
+ await main(manager, test_inventory, FAKE_CATALOG)
+
+ assert "No device in the established state 'True' was found. There is no device to run tests against, exiting" in [record.message for record in caplog.records]
+
+ # Reset logs and run with tags
+ caplog.clear()
+ await main(manager, test_inventory, FAKE_CATALOG, tags=["toto"])
+
+ assert "No device in the established state 'True' matching the tags ['toto'] was found. There is no device to run tests against, exiting" in [
+ record.message for record in caplog.records
+ ]
diff --git a/tests/units/tools/__init__.py b/tests/units/tools/__init__.py
new file mode 100644
index 0000000..e772bee
--- /dev/null
+++ b/tests/units/tools/__init__.py
@@ -0,0 +1,3 @@
+# 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.
diff --git a/tests/units/tools/test_get_dict_superset.py b/tests/units/tools/test_get_dict_superset.py
new file mode 100644
index 0000000..63e08b5
--- /dev/null
+++ b/tests/units/tools/test_get_dict_superset.py
@@ -0,0 +1,149 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+
+"""Tests for `anta.tools.get_dict_superset`."""
+from __future__ import annotations
+
+from contextlib import nullcontext as does_not_raise
+from typing import Any
+
+import pytest
+
+from anta.tools.get_dict_superset import get_dict_superset
+
+# pylint: disable=duplicate-code
+DUMMY_DATA = [
+ ("id", 0),
+ {
+ "id": 1,
+ "name": "Alice",
+ "age": 30,
+ "email": "alice@example.com",
+ },
+ {
+ "id": 2,
+ "name": "Bob",
+ "age": 35,
+ "email": "bob@example.com",
+ },
+ {
+ "id": 3,
+ "name": "Charlie",
+ "age": 40,
+ "email": "charlie@example.com",
+ },
+]
+
+
+@pytest.mark.parametrize(
+ "list_of_dicts, input_dict, default, required, var_name, custom_error_msg, expected_result, expected_raise",
+ [
+ pytest.param([], {"id": 1, "name": "Alice"}, None, False, None, None, None, does_not_raise(), id="empty list"),
+ pytest.param(
+ [],
+ {"id": 1, "name": "Alice"},
+ None,
+ True,
+ None,
+ None,
+ None,
+ pytest.raises(ValueError, match="not found in the provided list."),
+ id="empty list and required",
+ ),
+ pytest.param(DUMMY_DATA, {"id": 10, "name": "Jack"}, None, False, None, None, None, does_not_raise(), id="missing item"),
+ pytest.param(DUMMY_DATA, {"id": 1, "name": "Alice"}, None, False, None, None, DUMMY_DATA[1], does_not_raise(), id="found item"),
+ pytest.param(DUMMY_DATA, {"id": 10, "name": "Jack"}, "default_value", False, None, None, "default_value", does_not_raise(), id="default value"),
+ pytest.param(
+ DUMMY_DATA, {"id": 10, "name": "Jack"}, None, True, None, None, None, pytest.raises(ValueError, match="not found in the provided list."), id="required"
+ ),
+ pytest.param(
+ DUMMY_DATA,
+ {"id": 10, "name": "Jack"},
+ None,
+ True,
+ "custom_var_name",
+ None,
+ None,
+ pytest.raises(ValueError, match="custom_var_name not found in the provided list."),
+ id="custom var_name",
+ ),
+ pytest.param(
+ DUMMY_DATA, {"id": 1, "name": "Alice"}, None, True, "custom_var_name", "Custom error message", DUMMY_DATA[1], does_not_raise(), id="custom error message"
+ ),
+ pytest.param(
+ DUMMY_DATA,
+ {"id": 10, "name": "Jack"},
+ None,
+ True,
+ "custom_var_name",
+ "Custom error message",
+ None,
+ pytest.raises(ValueError, match="Custom error message"),
+ id="custom error message and required",
+ ),
+ pytest.param(DUMMY_DATA, {"id": 1, "name": "Jack"}, None, False, None, None, None, does_not_raise(), id="id ok but name not ok"),
+ pytest.param(
+ "not a list",
+ {"id": 1, "name": "Alice"},
+ None,
+ True,
+ None,
+ None,
+ None,
+ pytest.raises(ValueError, match="not found in the provided list."),
+ id="non-list input for list_of_dicts",
+ ),
+ pytest.param(
+ DUMMY_DATA, "not a dict", None, True, None, None, None, pytest.raises(ValueError, match="not found in the provided list."), id="non-dictionary input"
+ ),
+ pytest.param(DUMMY_DATA, {}, None, False, None, None, None, does_not_raise(), id="empty dictionary input"),
+ pytest.param(
+ DUMMY_DATA,
+ {"id": 1, "name": "Alice", "extra_key": "extra_value"},
+ None,
+ True,
+ None,
+ None,
+ None,
+ pytest.raises(ValueError, match="not found in the provided list."),
+ id="input dictionary with extra keys",
+ ),
+ pytest.param(
+ DUMMY_DATA,
+ {"id": 1},
+ None,
+ False,
+ None,
+ None,
+ DUMMY_DATA[1],
+ does_not_raise(),
+ id="input dictionary is a subset of more than one dictionary in list_of_dicts",
+ ),
+ pytest.param(
+ DUMMY_DATA,
+ {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com", "extra_key": "extra_value"},
+ None,
+ True,
+ None,
+ None,
+ None,
+ pytest.raises(ValueError, match="not found in the provided list."),
+ id="input dictionary is a superset of a dictionary in list_of_dicts",
+ ),
+ ],
+)
+def test_get_dict_superset(
+ list_of_dicts: list[dict[Any, Any]],
+ input_dict: Any,
+ default: Any | None,
+ required: bool,
+ var_name: str | None,
+ custom_error_msg: str | None,
+ expected_result: str,
+ expected_raise: Any,
+) -> None:
+ """Test get_dict_superset."""
+ # pylint: disable=too-many-arguments
+ with expected_raise:
+ assert get_dict_superset(list_of_dicts, input_dict, default, required, var_name, custom_error_msg) == expected_result
diff --git a/tests/units/tools/test_get_item.py b/tests/units/tools/test_get_item.py
new file mode 100644
index 0000000..7d75e9c
--- /dev/null
+++ b/tests/units/tools/test_get_item.py
@@ -0,0 +1,72 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+
+"""Tests for `anta.tools.get_item`."""
+from __future__ import annotations
+
+from contextlib import nullcontext as does_not_raise
+from typing import Any
+
+import pytest
+
+from anta.tools.get_item import get_item
+
+DUMMY_DATA = [
+ ("id", 0),
+ {
+ "id": 1,
+ "name": "Alice",
+ "age": 30,
+ "email": "alice@example.com",
+ },
+ {
+ "id": 2,
+ "name": "Bob",
+ "age": 35,
+ "email": "bob@example.com",
+ },
+ {
+ "id": 3,
+ "name": "Charlie",
+ "age": 40,
+ "email": "charlie@example.com",
+ },
+]
+
+
+@pytest.mark.parametrize(
+ "list_of_dicts, key, value, default, required, case_sensitive, var_name, custom_error_msg, expected_result, expected_raise",
+ [
+ pytest.param([], "name", "Bob", None, False, False, None, None, None, does_not_raise(), id="empty list"),
+ pytest.param([], "name", "Bob", None, True, False, None, None, None, pytest.raises(ValueError, match="name"), id="empty list and required"),
+ pytest.param(DUMMY_DATA, "name", "Jack", None, False, False, None, None, None, does_not_raise(), id="missing item"),
+ pytest.param(DUMMY_DATA, "name", "Alice", None, False, False, None, None, DUMMY_DATA[1], does_not_raise(), id="found item"),
+ pytest.param(DUMMY_DATA, "name", "Jack", "default_value", False, False, None, None, "default_value", does_not_raise(), id="default value"),
+ pytest.param(DUMMY_DATA, "name", "Jack", None, True, False, None, None, None, pytest.raises(ValueError, match="name"), id="required"),
+ pytest.param(DUMMY_DATA, "name", "Bob", None, False, True, None, None, DUMMY_DATA[2], does_not_raise(), id="case sensitive"),
+ pytest.param(DUMMY_DATA, "name", "charlie", None, False, False, None, None, DUMMY_DATA[3], does_not_raise(), id="case insensitive"),
+ pytest.param(
+ DUMMY_DATA, "name", "Jack", None, True, False, "custom_var_name", None, None, pytest.raises(ValueError, match="custom_var_name"), id="custom var_name"
+ ),
+ pytest.param(
+ DUMMY_DATA, "name", "Jack", None, True, False, None, "custom_error_msg", None, pytest.raises(ValueError, match="custom_error_msg"), id="custom error msg"
+ ),
+ ],
+)
+def test_get_item(
+ list_of_dicts: list[dict[Any, Any]],
+ key: Any,
+ value: Any,
+ default: Any | None,
+ required: bool,
+ case_sensitive: bool,
+ var_name: str | None,
+ custom_error_msg: str | None,
+ expected_result: str,
+ expected_raise: Any,
+) -> None:
+ """Test get_item."""
+ # pylint: disable=too-many-arguments
+ with expected_raise:
+ assert get_item(list_of_dicts, key, value, default, required, case_sensitive, var_name, custom_error_msg) == expected_result
diff --git a/tests/units/tools/test_get_value.py b/tests/units/tools/test_get_value.py
new file mode 100644
index 0000000..73344d1
--- /dev/null
+++ b/tests/units/tools/test_get_value.py
@@ -0,0 +1,50 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tools.get_value
+"""
+
+from __future__ import annotations
+
+from contextlib import nullcontext as does_not_raise
+from typing import Any
+
+import pytest
+
+from anta.tools.get_value import get_value
+
+INPUT_DICT = {"test_value": 42, "nested_test": {"nested_value": 43}}
+
+
+@pytest.mark.parametrize(
+ "input_dict, key, default, required, org_key, separator, expected_result, expected_raise",
+ [
+ pytest.param({}, "test", None, False, None, None, None, does_not_raise(), id="empty dict"),
+ pytest.param(INPUT_DICT, "test_value", None, False, None, None, 42, does_not_raise(), id="simple key"),
+ pytest.param(INPUT_DICT, "nested_test.nested_value", None, False, None, None, 43, does_not_raise(), id="nested_key"),
+ pytest.param(INPUT_DICT, "missing_value", None, False, None, None, None, does_not_raise(), id="missing_value"),
+ pytest.param(INPUT_DICT, "missing_value_with_default", "default_value", False, None, None, "default_value", does_not_raise(), id="default"),
+ pytest.param(INPUT_DICT, "missing_required", None, True, None, None, None, pytest.raises(ValueError), id="required"),
+ pytest.param(INPUT_DICT, "missing_required", None, True, "custom_org_key", None, None, pytest.raises(ValueError), id="custom org_key"),
+ pytest.param(INPUT_DICT, "nested_test||nested_value", None, None, None, "||", 43, does_not_raise(), id="custom separator"),
+ ],
+)
+def test_get_value(
+ input_dict: dict[Any, Any],
+ key: str,
+ default: str | None,
+ required: bool,
+ org_key: str | None,
+ separator: str | None,
+ expected_result: str,
+ expected_raise: Any,
+) -> None:
+ """
+ Test get_value
+ """
+ # pylint: disable=too-many-arguments
+ kwargs = {"default": default, "required": required, "org_key": org_key, "separator": separator}
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ with expected_raise:
+ assert get_value(input_dict, key, **kwargs) == expected_result # type: ignore
diff --git a/tests/units/tools/test_misc.py b/tests/units/tools/test_misc.py
new file mode 100644
index 0000000..c453c21
--- /dev/null
+++ b/tests/units/tools/test_misc.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""
+Tests for anta.tools.misc
+"""
+from __future__ import annotations
+
+import pytest
+
+from anta.tools.misc import exc_to_str, tb_to_str
+
+
+def my_raising_function(exception: Exception) -> None:
+ """
+ dummy function to raise Exception
+ """
+ raise exception
+
+
+@pytest.mark.parametrize("exception, expected_output", [(ValueError("test"), "ValueError (test)"), (ValueError(), "ValueError")])
+def test_exc_to_str(exception: Exception, expected_output: str) -> None:
+ """
+ Test exc_to_str
+ """
+ assert exc_to_str(exception) == expected_output
+
+
+def test_tb_to_str() -> None:
+ """
+ Test tb_to_str
+ """
+ try:
+ my_raising_function(ValueError("test"))
+ except ValueError as e:
+ output = tb_to_str(e)
+ assert "Traceback" in output
+ assert 'my_raising_function(ValueError("test"))' in output
diff --git a/tests/units/tools/test_utils.py b/tests/units/tools/test_utils.py
new file mode 100644
index 0000000..448324f
--- /dev/null
+++ b/tests/units/tools/test_utils.py
@@ -0,0 +1,57 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+
+"""Tests for `anta.tools.utils`."""
+from __future__ import annotations
+
+from contextlib import nullcontext as does_not_raise
+from typing import Any
+
+import pytest
+
+from anta.tools.utils import get_failed_logs
+
+EXPECTED_OUTPUTS = [
+ {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"},
+ {"id": 2, "name": "Bob", "age": 35, "email": "bob@example.com"},
+ {"id": 3, "name": "Charlie", "age": 40, "email": "charlie@example.com"},
+ {"id": 4, "name": "Jon", "age": 25, "email": "Jon@example.com"},
+]
+
+ACTUAL_OUTPUTS = [
+ {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"},
+ {"id": 2, "name": "Bob", "age": 35, "email": "bob@example.com"},
+ {"id": 3, "name": "Charlie", "age": 40, "email": "charlie@example.com"},
+ {"id": 4, "name": "Rob", "age": 25, "email": "Jon@example.com"},
+]
+
+
+@pytest.mark.parametrize(
+ "expected_output, actual_output, expected_result, expected_raise",
+ [
+ pytest.param(EXPECTED_OUTPUTS[0], ACTUAL_OUTPUTS[0], "", does_not_raise(), id="no difference"),
+ pytest.param(
+ EXPECTED_OUTPUTS[0],
+ ACTUAL_OUTPUTS[1],
+ "\nExpected `1` as the id, but found `2` instead.\nExpected `Alice` as the name, but found `Bob` instead.\n"
+ "Expected `30` as the age, but found `35` instead.\nExpected `alice@example.com` as the email, but found `bob@example.com` instead.",
+ does_not_raise(),
+ id="different data",
+ ),
+ pytest.param(
+ EXPECTED_OUTPUTS[0],
+ {},
+ "\nExpected `1` as the id, but it was not found in the actual output.\nExpected `Alice` as the name, but it was not found in the actual output.\n"
+ "Expected `30` as the age, but it was not found in the actual output.\nExpected `alice@example.com` as the email, but it was not found in "
+ "the actual output.",
+ does_not_raise(),
+ id="empty actual output",
+ ),
+ pytest.param(EXPECTED_OUTPUTS[3], ACTUAL_OUTPUTS[3], "\nExpected `Jon` as the name, but found `Rob` instead.", does_not_raise(), id="different name"),
+ ],
+)
+def test_get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any], expected_result: str, expected_raise: Any) -> None:
+ """Test get_failed_logs."""
+ with expected_raise:
+ assert get_failed_logs(expected_output, actual_output) == expected_result