diff options
Diffstat (limited to 'tests/units')
67 files changed, 15394 insertions, 0 deletions
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 |