From b652ed18a764e29877e4e2feca42ccd1ccb01a7f Mon Sep 17 00:00:00 2001 From: geetanjalimanegslab <96573243+geetanjalimanegslab@users.noreply.github.com> Date: Thu, 13 Feb 2025 10:37:37 +0530 Subject: [PATCH 01/13] =?UTF-8?q?refactor(anta.tests):=20Nicer=20result=20?= =?UTF-8?q?failure=20messages=20AVT,=20field=5Fnotice=20test=20module?= =?UTF-8?q?=C2=A0=20(#1036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(anta.tests): Nicer result failure messages AVT, Field_notice test moduleĀ  * Remove comas from the input docstring --- anta/input_models/avt.py | 2 +- anta/tests/avt.py | 2 +- anta/tests/field_notices.py | 4 ++-- tests/units/anta_tests/test_avt.py | 18 +++++++++--------- tests/units/anta_tests/test_field_notices.py | 10 +++++----- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/anta/input_models/avt.py b/anta/input_models/avt.py index 6430045eb..44fd780fc 100644 --- a/anta/input_models/avt.py +++ b/anta/input_models/avt.py @@ -33,4 +33,4 @@ def __str__(self) -> str: AVT CONTROL-PLANE-PROFILE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1) """ - return f"AVT {self.avt_name} VRF: {self.vrf} (Destination: {self.destination}, Next-hop: {self.next_hop})" + return f"AVT: {self.avt_name} VRF: {self.vrf} Destination: {self.destination} Next-hop: {self.next_hop}" diff --git a/anta/tests/avt.py b/anta/tests/avt.py index 365a2f72a..499b25057 100644 --- a/anta/tests/avt.py +++ b/anta/tests/avt.py @@ -192,4 +192,4 @@ def test(self) -> None: # Check if the AVT role matches the expected role if self.inputs.role != command_output.get("role"): - self.result.is_failure(f"Expected AVT role as `{self.inputs.role}`, but found `{command_output.get('role')}` instead.") + self.result.is_failure(f"AVT role mismatch - Expected: {self.inputs.role}, Actual: {command_output.get('role')}") diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py index 0c093e796..cc7fab9f0 100644 --- a/anta/tests/field_notices.py +++ b/anta/tests/field_notices.py @@ -96,7 +96,7 @@ def test(self) -> None: for variant in variants: model = model.replace(variant, "") if model not in devices: - self.result.is_skipped("device is not impacted by FN044") + self.result.is_skipped("Device is not impacted by FN044") return for component in command_output["details"]["components"]: @@ -117,7 +117,7 @@ def test(self) -> None: ) ) if incorrect_aboot_version: - self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") + self.result.is_failure(f"Device is running incorrect version of aboot {aboot_version}") class VerifyFieldNotice72Resolution(AntaTest): diff --git a/tests/units/anta_tests/test_avt.py b/tests/units/anta_tests/test_avt.py index bb6c6b903..60adbec28 100644 --- a/tests/units/anta_tests/test_avt.py +++ b/tests/units/anta_tests/test_avt.py @@ -444,7 +444,7 @@ }, "expected": { "result": "failure", - "messages": ["AVT MGMT-AVT-POLICY-DEFAULT VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1) - No AVT path configured"], + "messages": ["AVT: MGMT-AVT-POLICY-DEFAULT VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.1 - No AVT path configured"], }, }, { @@ -507,8 +507,8 @@ "expected": { "result": "failure", "messages": [ - "AVT DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.11) Path Type: multihop - Path not found", - "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.21) Path Type: direct - Path not found", + "AVT: DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.11 Path Type: multihop - Path not found", + "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.21 Path Type: direct - Path not found", ], }, }, @@ -571,8 +571,8 @@ "expected": { "result": "failure", "messages": [ - "AVT DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.11) - Path not found", - "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.21) - Path not found", + "AVT: DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.11 - Path not found", + "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.21 - Path not found", ], }, }, @@ -646,11 +646,11 @@ "expected": { "result": "failure", "messages": [ - "AVT DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1) - " + "AVT: DEFAULT-AVT-POLICY-CONTROL-PLANE VRF: default Destination: 10.101.255.2 Next-hop: 10.101.255.1 - " "Incorrect path multihop:3 - Valid: False, Active: True", - "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.1) - " + "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.1 - " "Incorrect path direct:10 - Valid: False, Active: True", - "AVT DATA-AVT-POLICY-CONTROL-PLANE VRF: data (Destination: 10.101.255.1, Next-hop: 10.101.255.1) - " + "AVT: DATA-AVT-POLICY-CONTROL-PLANE VRF: data Destination: 10.101.255.1 Next-hop: 10.101.255.1 - " "Incorrect path direct:9 - Valid: True, Active: False", ], }, @@ -667,6 +667,6 @@ "test": VerifyAVTRole, "eos_data": [{"role": "transit"}], "inputs": {"role": "edge"}, - "expected": {"result": "failure", "messages": ["Expected AVT role as `edge`, but found `transit` instead."]}, + "expected": {"result": "failure", "messages": ["AVT role mismatch - Expected: edge, Actual: transit"]}, }, ] diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py index 13dd66095..f7c5fc681 100644 --- a/tests/units/anta_tests/test_field_notices.py +++ b/tests/units/anta_tests/test_field_notices.py @@ -45,7 +45,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["device is running incorrect version of aboot (4.0.1)"], + "messages": ["Device is running incorrect version of aboot 4.0.1"], }, }, { @@ -65,7 +65,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["device is running incorrect version of aboot (4.1.0)"], + "messages": ["Device is running incorrect version of aboot 4.1.0"], }, }, { @@ -85,7 +85,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["device is running incorrect version of aboot (6.0.1)"], + "messages": ["Device is running incorrect version of aboot 6.0.1"], }, }, { @@ -105,7 +105,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["device is running incorrect version of aboot (6.1.1)"], + "messages": ["Device is running incorrect version of aboot 6.1.1"], }, }, { @@ -125,7 +125,7 @@ "inputs": None, "expected": { "result": "skipped", - "messages": ["device is not impacted by FN044"], + "messages": ["Device is not impacted by FN044"], }, }, { From 6eabc5297484f7be88c0fb7c2d92c8d3f4525b92 Mon Sep 17 00:00:00 2001 From: geetanjalimanegslab <96573243+geetanjalimanegslab@users.noreply.github.com> Date: Thu, 13 Feb 2025 22:31:39 +0530 Subject: [PATCH 02/13] feat(anta): Added testcase to verify the BGP Redistributed Routes (#993) --- anta/custom_types.py | 83 ++++- anta/input_models/routing/bgp.py | 75 ++++- anta/tests/routing/bgp.py | 98 +++++- examples/tests.yaml | 20 ++ tests/units/anta_tests/routing/test_bgp.py | 330 +++++++++++++++++++ tests/units/input_models/routing/test_bgp.py | 93 +++++- 6 files changed, 688 insertions(+), 11 deletions(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index ccd0b5f6e..aa36d2fc7 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -24,6 +24,13 @@ """Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`.""" +# Regular expression for BGP redistributed routes +REGEX_IPV4_UNICAST = r"ipv4[-_ ]?unicast$" +REGEX_IPV4_MULTICAST = r"ipv4[-_ ]?multicast$" +REGEX_IPV6_UNICAST = r"ipv6[-_ ]?unicast$" +REGEX_IPV6_MULTICAST = r"ipv6[-_ ]?multicast$" + + def aaa_group_prefix(v: str) -> str: """Prefix the AAA method with 'group' if it is known.""" built_in_methods = ["local", "none", "logging"] @@ -92,10 +99,10 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: patterns = { f"{r'dynamic[-_ ]?path[-_ ]?selection$'}": "dps", f"{r'dps$'}": "dps", - f"{r'ipv4[-_ ]?unicast$'}": "ipv4Unicast", - f"{r'ipv6[-_ ]?unicast$'}": "ipv6Unicast", - f"{r'ipv4[-_ ]?multicast$'}": "ipv4Multicast", - f"{r'ipv6[-_ ]?multicast$'}": "ipv6Multicast", + f"{REGEX_IPV4_UNICAST}": "ipv4Unicast", + f"{REGEX_IPV6_UNICAST}": "ipv6Unicast", + f"{REGEX_IPV4_MULTICAST}": "ipv4Multicast", + f"{REGEX_IPV6_MULTICAST}": "ipv6Multicast", f"{r'ipv4[-_ ]?labeled[-_ ]?Unicast$'}": "ipv4MplsLabels", f"{r'ipv4[-_ ]?mpls[-_ ]?labels$'}": "ipv4MplsLabels", f"{r'ipv6[-_ ]?labeled[-_ ]?Unicast$'}": "ipv6MplsLabels", @@ -132,6 +139,54 @@ def validate_regex(value: str) -> str: return value +def bgp_redistributed_route_proto_abbreviations(value: str) -> str: + """Abbreviations for different BGP redistributed route protocols. + + Handles different separators (hyphen, underscore, space) and case sensitivity. + + Examples + -------- + ```python + >>> bgp_redistributed_route_proto_abbreviations("IPv4 Unicast") + 'v4u' + >>> bgp_redistributed_route_proto_abbreviations("IPv4-multicast") + 'v4m' + >>> bgp_redistributed_route_proto_abbreviations("IPv6_multicast") + 'v6m' + >>> bgp_redistributed_route_proto_abbreviations("ipv6unicast") + 'v6u' + ``` + """ + patterns = {REGEX_IPV4_UNICAST: "v4u", REGEX_IPV4_MULTICAST: "v4m", REGEX_IPV6_UNICAST: "v6u", REGEX_IPV6_MULTICAST: "v6m"} + + for pattern, replacement in patterns.items(): + match = re.match(pattern, value, re.IGNORECASE) + if match: + return replacement + + return value + + +def update_bgp_redistributed_proto_user(value: str) -> str: + """Update BGP redistributed route `User` proto with EOS SDK. + + Examples + -------- + ```python + >>> update_bgp_redistributed_proto_user("User") + 'EOS SDK' + >>> update_bgp_redistributed_proto_user("Bgp") + 'Bgp' + >>> update_bgp_redistributed_proto_user("RIP") + 'RIP' + ``` + """ + if value == "User": + value = "EOS SDK" + + return value + + # AntaTest.Input types AAAAuthMethod = Annotated[str, AfterValidator(aaa_group_prefix)] Vlan = Annotated[int, Field(ge=0, le=4094)] @@ -319,3 +374,23 @@ def snmp_v3_prefix(auth_type: Literal["auth", "priv", "noauth"]) -> str: "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs" ] SnmpVersionV3AuthType = Annotated[Literal["auth", "priv", "noauth"], AfterValidator(snmp_v3_prefix)] +RedistributedProtocol = Annotated[ + Literal[ + "AttachedHost", + "Bgp", + "Connected", + "Dynamic", + "IS-IS", + "OSPF Internal", + "OSPF External", + "OSPF Nssa-External", + "OSPFv3 Internal", + "OSPFv3 External", + "OSPFv3 Nssa-External", + "RIP", + "Static", + "User", + ], + AfterValidator(update_bgp_redistributed_proto_user), +] +RedistributedAfiSafi = Annotated[Literal["v4u", "v4m", "v6u", "v6m"], BeforeValidator(bgp_redistributed_route_proto_abbreviations)] diff --git a/anta/input_models/routing/bgp.py b/anta/input_models/routing/bgp.py index 945b0305c..5c9226ec1 100644 --- a/anta/input_models/routing/bgp.py +++ b/anta/input_models/routing/bgp.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, Safi, Vni +from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, RedistributedAfiSafi, RedistributedProtocol, Safi, Vni if TYPE_CHECKING: import sys @@ -68,8 +68,7 @@ class BgpAddressFamily(BaseModel): check_peer_state: bool = False """Flag to check if the peers are established with negotiated AFI/SAFI. Defaults to `False`. - Can be enabled in the `VerifyBGPPeerCount` tests. - """ + Can be enabled in the `VerifyBGPPeerCount` tests.""" @model_validator(mode="after") def validate_inputs(self) -> Self: @@ -256,3 +255,73 @@ def __str__(self) -> str: - Next-hop: 192.168.66.101 Origin: Igp """ return f"Next-hop: {self.nexthop} Origin: {self.origin}" + + +class BgpVrf(BaseModel): + """Model representing a VRF in a BGP instance.""" + + vrf: str = "default" + """VRF context.""" + address_families: list[AddressFamilyConfig] + """List of address family configuration.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the BgpVrf for reporting. + + Examples + -------- + - VRF: default + """ + return f"VRF: {self.vrf}" + + +class RedistributedRouteConfig(BaseModel): + """Model representing a BGP redistributed route configuration.""" + + proto: RedistributedProtocol + """The redistributed protocol.""" + include_leaked: bool = False + """Flag to include leaked routes of the redistributed protocol while redistributing.""" + route_map: str | None = None + """Optional route map applied to the redistribution.""" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate that 'include_leaked' is not set when the redistributed protocol is AttachedHost, User, Dynamic, or RIP.""" + if self.include_leaked and self.proto in ["AttachedHost", "EOS SDK", "Dynamic", "RIP"]: + msg = f"'include_leaked' field is not supported for redistributed protocol '{self.proto}'" + raise ValueError(msg) + return self + + def __str__(self) -> str: + """Return a human-readable string representation of the RedistributedRouteConfig for reporting. + + Examples + -------- + - Proto: Connected, Include Leaked: True, Route Map: RM-CONN-2-BGP + """ + base_string = f"Proto: {self.proto}" + if self.include_leaked: + base_string += f", Include Leaked: {self.include_leaked}" + if self.route_map: + base_string += f", Route Map: {self.route_map}" + return base_string + + +class AddressFamilyConfig(BaseModel): + """Model representing a BGP address family configuration.""" + + afi_safi: RedistributedAfiSafi + """AFI/SAFI abbreviation per EOS.""" + redistributed_routes: list[RedistributedRouteConfig] + """List of redistributed route configuration.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the AddressFamilyConfig for reporting. + + Examples + -------- + - AFI-SAFI: IPv4 Unicast + """ + mappings = {"v4u": "IPv4 Unicast", "v4m": "IPv4 Multicast", "v6u": "IPv6 Unicast", "v6m": "IPv6 Multicast"} + return f"AFI-SAFI: {mappings[self.afi_safi]}" diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index b2677340f..7522c2549 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -11,13 +11,15 @@ from pydantic import field_validator -from anta.input_models.routing.bgp import BgpAddressFamily, BgpAfi, BgpNeighbor, BgpPeer, BgpRoute, VxlanEndpoint +from anta.input_models.routing.bgp import BgpAddressFamily, BgpAfi, BgpNeighbor, BgpPeer, BgpRoute, BgpVrf, VxlanEndpoint from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import format_data, get_item, get_value # Using a TypeVar for the BgpPeer model since mypy thinks it's a ClassVar and not a valid type when used in field validators T = TypeVar("T", bound=BgpPeer) +# TODO: Refactor to reduce the number of lines in this module later + def _check_bgp_neighbor_capability(capability_status: dict[str, bool]) -> bool: """Check if a BGP neighbor capability is advertised, received, and enabled. @@ -1797,3 +1799,97 @@ def test(self) -> None: # Verify BGP and RIB nexthops are same. if len(bgp_nexthops) != len(route_entry["vias"]): self.result.is_failure(f"{route} - Nexthops count mismatch - BGP: {len(bgp_nexthops)}, RIB: {len(route_entry['vias'])}") + + +class VerifyBGPRedistribution(AntaTest): + """Verifies BGP redistribution. + + This test performs the following checks for each specified VRF in the BGP instance: + + 1. Ensures that the expected address-family is configured on the device. + 2. Confirms that the redistributed route protocol, include leaked and route map match the expected values. + + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - The expected address-family is configured on the device. + - The redistributed route protocol, include leaked and route map align with the expected values for the route. + * Failure: If any of the following occur: + - The expected address-family is not configured on device. + - The redistributed route protocol, include leaked or route map does not match the expected values. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPRedistribution: + vrfs: + - vrf: default + address_families: + - afi_safi: ipv4Unicast + redistributed_routes: + - proto: Connected + include_leaked: True + route_map: RM-CONN-2-BGP + - proto: Static + include_leaked: True + route_map: RM-CONN-2-BGP + - afi_safi: IPv6 Unicast + redistributed_routes: + - proto: User # Converted to EOS SDK + route_map: RM-CONN-2-BGP + - proto: Static + include_leaked: True + route_map: RM-CONN-2-BGP + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp instance vrf all", revision=4)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPRedistribution test.""" + + vrfs: list[BgpVrf] + """List of VRFs in the BGP instance.""" + + def _validate_redistribute_route(self, vrf_data: str, addr_family: str, afi_safi_configs: list[dict[str, Any]], route_info: dict[str, Any]) -> list[Any]: + """Validate the redstributed route details for a given address family.""" + failure_msg = [] + # If the redistributed route protocol does not match the expected value, test fails. + if not (actual_route := get_item(afi_safi_configs.get("redistributedRoutes"), "proto", route_info.proto)): + failure_msg.append(f"{vrf_data}, {addr_family}, Proto: {route_info.proto} - Not configured") + return failure_msg + + # If includes leaked field applicable, and it does not matches the expected value, test fails. + if (act_include_leaked := actual_route.get("includeLeaked", False)) != route_info.include_leaked: + failure_msg.append(f"{vrf_data}, {addr_family}, {route_info} - Include leaked mismatch - Actual: {act_include_leaked}") + + # If route map is required and it is not matching the expected value, test fails. + if all([route_info.route_map, (act_route_map := actual_route.get("routeMap", "Not Found")) != route_info.route_map]): + failure_msg.append(f"{vrf_data}, {addr_family}, {route_info} - Route map mismatch - Actual: {act_route_map}") + return failure_msg + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPRedistribution.""" + self.result.is_success() + command_output = self.instance_commands[0].json_output + + for vrf_data in self.inputs.vrfs: + # If the specified VRF details are not found, test fails. + if not (instance_details := get_value(command_output, f"vrfs.{vrf_data.vrf}")): + self.result.is_failure(f"{vrf_data} - Not configured") + continue + for address_family in vrf_data.address_families: + # If the AFI-SAFI configuration details are not found, test fails. + if not (afi_safi_configs := get_value(instance_details, f"afiSafiConfig.{address_family.afi_safi}")): + self.result.is_failure(f"{vrf_data}, {address_family} - Not redistributed") + continue + + for route_info in address_family.redistributed_routes: + failure_msg = self._validate_redistribute_route(str(vrf_data), str(address_family), afi_safi_configs, route_info) + for msg in failure_msg: + self.result.is_failure(msg) diff --git a/examples/tests.yaml b/examples/tests.yaml index e4f08a937..f5fd3ebd0 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -513,6 +513,26 @@ anta.tests.routing.bgp: - VerifyBGPPeersHealthRibd: # Verifies the health of all the BGP IPv4 peer(s). check_tcp_queues: True + - VerifyBGPRedistribution: + # Verifies BGP redistribution. + vrfs: + - vrf: default + address_families: + - afi_safi: ipv4Unicast + redistributed_routes: + - proto: Connected + include_leaked: True + route_map: RM-CONN-2-BGP + - proto: Static + include_leaked: True + route_map: RM-CONN-2-BGP + - afi_safi: IPv6 Unicast + redistributed_routes: + - proto: User # Converted to EOS SDK + route_map: RM-CONN-2-BGP + - proto: Static + include_leaked: True + route_map: RM-CONN-2-BGP - VerifyBGPRouteECMP: # Verifies BGP IPv4 route ECMP paths. route_entries: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 418c2d0e1..ee26155f3 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -28,6 +28,7 @@ VerifyBGPPeersHealth, VerifyBGPPeersHealthRibd, VerifyBGPPeerUpdateErrors, + VerifyBGPRedistribution, VerifyBGPRouteECMP, VerifyBgpRouteMaps, VerifyBGPRoutePaths, @@ -5788,4 +5789,333 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]}, "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - Nexthops count mismatch - BGP: 2, RIB: 1"]}, }, + { + "name": "success", + "test": VerifyBGPRedistribution, + "eos_data": [ + { + "vrfs": { + "default": { + "afiSafiConfig": { + "v4u": { + "redistributedRoutes": [ + {"proto": "Connected", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"}, + {"proto": "Static", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"}, + ] + }, + "v6m": { + "redistributedRoutes": [ + {"proto": "Dynamic", "routeMap": "RM-CONN-2-BGP"}, + {"proto": "IS-IS", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"}, + ] + }, + } + }, + "test": { + "afiSafiConfig": { + "v4u": { + "redistributedRoutes": [ + {"proto": "EOS SDK", "routeMap": "RM-CONN-2-BGP"}, + {"proto": "OSPF Internal", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"}, + ] + }, + "v6m": { + "redistributedRoutes": [ + {"proto": "RIP", "routeMap": "RM-CONN-2-BGP"}, + {"proto": "Bgp", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"}, + ] + }, + } + }, + } + } + ], + "inputs": { + "vrfs": [ + { + "vrf": "default", + "address_families": [ + { + "afi_safi": "ipv4Unicast", + "redistributed_routes": [ + {"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + {"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + { + "afi_safi": "IPv6 multicast", + "redistributed_routes": [ + {"proto": "Dynamic", "route_map": "RM-CONN-2-BGP"}, + {"proto": "IS-IS", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + { + "vrf": "test", + "address_families": [ + { + "afi_safi": "ipv4 Unicast", + "redistributed_routes": [ + {"proto": "User", "route_map": "RM-CONN-2-BGP"}, + {"proto": "OSPF Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + { + "afi_safi": "IPv6Multicast", + "redistributed_routes": [ + {"proto": "RIP", "route_map": "RM-CONN-2-BGP"}, + {"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-vrf-not-found", + "test": VerifyBGPRedistribution, + "eos_data": [ + { + "vrfs": { + "default": {"afiSafiConfig": {"v6m": {"redistributedRoutes": [{"proto": "Connected", "routeMap": "RM-CONN-2-BGP"}]}}}, + "tenant": {"afiSafiConfig": {"v4u": {"redistributedRoutes": [{"proto": "Connected"}]}}}, + } + } + ], + "inputs": { + "vrfs": [ + { + "vrf": "default", + "address_families": [ + { + "afi_safi": "ipv6 Multicast", + "redistributed_routes": [{"proto": "Connected", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], + }, + ], + }, + { + "vrf": "test", + "address_families": [ + { + "afi_safi": "ipv6 Multicast", + "redistributed_routes": [ + {"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + ] + }, + "expected": {"result": "failure", "messages": ["VRF: test - Not configured"]}, + }, + { + "name": "failure-afi-safi-config-not-found", + "test": VerifyBGPRedistribution, + "eos_data": [ + { + "vrfs": { + "default": {"afiSafiConfig": {"v6m": {}}}, + "test": {"afiSafiConfig": {"v4u": {"redistributedRoutes": [{"proto": "Connected", "routeMap": "RM-CONN-2-BGP"}]}}}, + } + } + ], + "inputs": { + "vrfs": [ + { + "vrf": "default", + "address_families": [ + { + "afi_safi": "ipv6 Multicast", + "redistributed_routes": [ + {"proto": "Connected", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + {"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + ] + }, + "expected": {"result": "failure", "messages": ["VRF: default, AFI-SAFI: IPv6 Multicast - Not redistributed"]}, + }, + { + "name": "failure-expected-proto-not-found", + "test": VerifyBGPRedistribution, + "eos_data": [ + { + "vrfs": { + "default": { + "afiSafiConfig": { + "v4m": {"redistributedRoutes": [{"proto": "RIP", "routeMap": "RM-CONN-2-BGP"}, {"proto": "IS-IS", "routeMap": "RM-MLAG-PEER-IN"}]} + } + }, + "test": { + "afiSafiConfig": { + "v6u": { + "redistributedRoutes": [{"proto": "Static", "routeMap": "RM-CONN-2-BGP"}], + } + } + }, + } + } + ], + "inputs": { + "vrfs": [ + { + "vrf": "default", + "address_families": [ + { + "afi_safi": "ipv4 multicast", + "redistributed_routes": [ + {"proto": "OSPFv3 External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + {"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + } + ], + }, + { + "vrf": "test", + "address_families": [ + { + "afi_safi": "IPv6Unicast", + "redistributed_routes": [ + {"proto": "RIP", "route_map": "RM-CONN-2-BGP"}, + {"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "VRF: default, AFI-SAFI: IPv4 Multicast, Proto: OSPFv3 External - Not configured", + "VRF: default, AFI-SAFI: IPv4 Multicast, Proto: OSPFv3 Nssa-External - Not configured", + "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: RIP - Not configured", + "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: Bgp - Not configured", + ], + }, + }, + { + "name": "failure-route-map-not-found", + "test": VerifyBGPRedistribution, + "eos_data": [ + { + "vrfs": { + "default": {"afiSafiConfig": {"v4u": {"redistributedRoutes": [{"proto": "Connected", "routeMap": "RM-CONN-10-BGP"}, {"proto": "Static"}]}}}, + "test": { + "afiSafiConfig": { + "v6u": { + "redistributedRoutes": [{"proto": "EOS SDK", "routeMap": "RM-MLAG-PEER-IN"}, {"proto": "OSPF Internal"}], + } + } + }, + } + } + ], + "inputs": { + "vrfs": [ + { + "vrf": "default", + "address_families": [ + { + "afi_safi": "ipv4 UNicast", + "redistributed_routes": [ + {"proto": "Connected", "route_map": "RM-CONN-2-BGP"}, + {"proto": "Static", "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + { + "vrf": "test", + "address_families": [ + { + "afi_safi": "ipv6-Unicast", + "redistributed_routes": [ + {"proto": "User", "route_map": "RM-CONN-2-BGP"}, + {"proto": "OSPF Internal", "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "VRF: default, AFI-SAFI: IPv4 Unicast, Proto: Connected, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: RM-CONN-10-BGP", + "VRF: default, AFI-SAFI: IPv4 Unicast, Proto: Static, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: Not Found", + "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: EOS SDK, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: RM-MLAG-PEER-IN", + "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: OSPF Internal, Route Map: RM-CONN-2-BGP - Route map mismatch - Actual: Not Found", + ], + }, + }, + { + "name": "failure-incorrect-value-include-leaked", + "test": VerifyBGPRedistribution, + "eos_data": [ + { + "vrfs": { + "default": { + "afiSafiConfig": { + "v4m": { + "redistributedRoutes": [ + {"proto": "Dynamic", "routeMap": "RM-CONN-2-BGP"}, + {"proto": "IS-IS", "includeLeaked": False, "routeMap": "RM-CONN-2-BGP"}, + ] + }, + } + }, + "test": { + "afiSafiConfig": { + "v6u": { + "redistributedRoutes": [ + {"proto": "RIP", "routeMap": "RM-CONN-2-BGP"}, + {"proto": "Bgp", "includeLeaked": True, "routeMap": "RM-CONN-2-BGP"}, + ] + }, + } + }, + } + } + ], + "inputs": { + "vrfs": [ + { + "vrf": "default", + "address_families": [ + { + "afi_safi": "ipv4-multicast", + "redistributed_routes": [ + {"proto": "IS-IS", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + { + "vrf": "test", + "address_families": [ + { + "afi_safi": "IPv6_unicast", + "redistributed_routes": [ + {"proto": "RIP", "route_map": "RM-CONN-2-BGP"}, + {"proto": "Bgp", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}, + ], + }, + ], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "VRF: default, AFI-SAFI: IPv4 Multicast, Proto: IS-IS, Include Leaked: True, Route Map: RM-CONN-2-BGP - Include leaked mismatch - Actual: False", + "VRF: test, AFI-SAFI: IPv6 Unicast, Proto: Bgp, Route Map: RM-CONN-2-BGP - Include leaked mismatch - Actual: True", + ], + }, + }, ] diff --git a/tests/units/input_models/routing/test_bgp.py b/tests/units/input_models/routing/test_bgp.py index d8d64507f..0ff859ff6 100644 --- a/tests/units/input_models/routing/test_bgp.py +++ b/tests/units/input_models/routing/test_bgp.py @@ -6,12 +6,12 @@ # pylint: disable=C0302 from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest from pydantic import ValidationError -from anta.input_models.routing.bgp import BgpAddressFamily, BgpPeer, BgpRoute +from anta.input_models.routing.bgp import AddressFamilyConfig, BgpAddressFamily, BgpPeer, BgpRoute, RedistributedRouteConfig from anta.tests.routing.bgp import ( VerifyBGPExchangedRoutes, VerifyBGPNlriAcceptance, @@ -27,7 +27,7 @@ ) if TYPE_CHECKING: - from anta.custom_types import Afi, Safi + from anta.custom_types import Afi, RedistributedAfiSafi, RedistributedProtocol, Safi class TestBgpAddressFamily: @@ -348,3 +348,90 @@ def test_invalid(self, route_entries: list[BgpRoute]) -> None: """Test VerifyBGPRoutePaths.Input invalid inputs.""" with pytest.raises(ValidationError): VerifyBGPRoutePaths.Input(route_entries=route_entries) + + +class TestVerifyBGPRedistributedRoute: + """Test anta.input_models.routing.bgp.RedistributedRouteConfig.""" + + @pytest.mark.parametrize( + ("proto", "include_leaked"), + [ + pytest.param("Connected", True, id="proto-valid"), + pytest.param("Static", False, id="proto-valid-leaked-false"), + pytest.param("User", False, id="proto-User"), + ], + ) + def test_validate_inputs(self, proto: RedistributedProtocol, include_leaked: bool) -> None: + """Test RedistributedRouteConfig valid inputs.""" + RedistributedRouteConfig(proto=proto, include_leaked=include_leaked) + + @pytest.mark.parametrize( + ("proto", "include_leaked"), + [ + pytest.param("Dynamic", True, id="proto-valid"), + pytest.param("User", True, id="proto-valid-leaked-false"), + ], + ) + def test_invalid(self, proto: RedistributedProtocol, include_leaked: bool) -> None: + """Test RedistributedRouteConfig invalid inputs.""" + with pytest.raises(ValidationError): + RedistributedRouteConfig(proto=proto, include_leaked=include_leaked) + + @pytest.mark.parametrize( + ("proto", "include_leaked", "route_map", "expected"), + [ + pytest.param("Connected", True, "RM-CONN-2-BGP", "Proto: Connected, Include Leaked: True, Route Map: RM-CONN-2-BGP", id="check-all-params"), + pytest.param("Static", False, None, "Proto: Static", id="check-proto-include_leaked-false"), + pytest.param("User", False, "RM-CONN-2-BGP", "Proto: EOS SDK, Route Map: RM-CONN-2-BGP", id="check-proto-route_map"), + pytest.param("Dynamic", False, None, "Proto: Dynamic", id="check-proto-only"), + ], + ) + def test_valid_str(self, proto: RedistributedProtocol, include_leaked: bool, route_map: str | None, expected: str) -> None: + """Test RedistributedRouteConfig __str__.""" + assert str(RedistributedRouteConfig(proto=proto, include_leaked=include_leaked, route_map=route_map)) == expected + + +class TestVerifyBGPAddressFamilyConfig: + """Test anta.input_models.routing.bgp.AddressFamilyConfig.""" + + @pytest.mark.parametrize( + ("afi_safi", "redistributed_routes"), + [ + pytest.param("ipv4Unicast", [{"proto": "OSPFv3 External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv4-unicast"), + pytest.param("ipv6 Multicast", [{"proto": "OSPF Internal", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv6-multicast"), + pytest.param("ipv4-Multicast", [{"proto": "IS-IS", "include_leaked": False, "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv4-multicast"), + pytest.param("ipv6_Unicast", [{"proto": "AttachedHost", "route_map": "RM-CONN-2-BGP"}], id="afisafi-ipv6-unicast"), + ], + ) + def test_valid(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any]) -> None: + """Test AddressFamilyConfig valid inputs.""" + AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes) + + @pytest.mark.parametrize( + ("afi_safi", "redistributed_routes"), + [ + pytest.param("evpn", [{"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="invalid-address-family"), + pytest.param("ipv6 sr-te", [{"proto": "RIP", "route_map": "RM-CONN-2-BGP"}], id="ipv6-invalid-address-family"), + pytest.param("iipv6_Unicast", [{"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="ipv6-unicast-invalid-address-family"), + pytest.param("ipv6_Unicastt", [{"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], id="ipv6-unicast-invalid-address-family"), + ], + ) + def test_invalid(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any]) -> None: + """Test AddressFamilyConfig invalid inputs.""" + with pytest.raises(ValidationError): + AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes) + + @pytest.mark.parametrize( + ("afi_safi", "redistributed_routes", "expected"), + [ + pytest.param( + "v4u", [{"proto": "OSPFv3 Nssa-External", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv4 Unicast", id="valid-ipv4-unicast" + ), + pytest.param("v4m", [{"proto": "RIP", "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv4 Multicast", id="valid-ipv4-multicast"), + pytest.param("v6u", [{"proto": "Bgp", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv6 Unicast", id="valid-ipv6-unicast"), + pytest.param("v6m", [{"proto": "Static", "include_leaked": True, "route_map": "RM-CONN-2-BGP"}], "AFI-SAFI: IPv6 Multicast", id="valid-ipv6-multicast"), + ], + ) + def test_valid_str(self, afi_safi: RedistributedAfiSafi, redistributed_routes: list[Any], expected: str) -> None: + """Test AddressFamilyConfig __str__.""" + assert str(AddressFamilyConfig(afi_safi=afi_safi, redistributed_routes=redistributed_routes)) == expected From 593959cd7b65e34e41ca042386f48284021c99c1 Mon Sep 17 00:00:00 2001 From: geetanjalimanegslab <96573243+geetanjalimanegslab@users.noreply.github.com> Date: Thu, 13 Feb 2025 22:38:43 +0530 Subject: [PATCH 03/13] =?UTF-8?q?refactor(anta.tests):=20Nicer=20result=20?= =?UTF-8?q?failure=20messages=20Service=20and=20SNMP=20test=20module=C2=A0?= =?UTF-8?q?=20(#1041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- anta/input_models/services.py | 2 +- anta/tests/services.py | 2 +- anta/tests/snmp.py | 58 ++++++++++--------------- tests/units/anta_tests/test_services.py | 10 ++--- tests/units/anta_tests/test_snmp.py | 24 +++++----- 5 files changed, 42 insertions(+), 54 deletions(-) diff --git a/anta/input_models/services.py b/anta/input_models/services.py index 25d772e41..0c602c8e6 100644 --- a/anta/input_models/services.py +++ b/anta/input_models/services.py @@ -32,7 +32,7 @@ def __str__(self) -> str: -------- Server 10.0.0.1 (VRF: default, Priority: 1) """ - return f"Server {self.server_address} (VRF: {self.vrf}, Priority: {self.priority})" + return f"Server {self.server_address} VRF: {self.vrf} Priority: {self.priority}" class ErrdisableRecovery(BaseModel): diff --git a/anta/tests/services.py b/anta/tests/services.py index 8d8942160..a2b09da3b 100644 --- a/anta/tests/services.py +++ b/anta/tests/services.py @@ -46,7 +46,7 @@ def test(self) -> None: hostname = self.instance_commands[0].json_output["hostname"] if hostname != self.inputs.hostname: - self.result.is_failure(f"Expected `{self.inputs.hostname}` as the hostname, but found `{hostname}` instead.") + self.result.is_failure(f"Incorrect Hostname - Expected: {self.inputs.hostname} Actual: {hostname}") else: self.result.is_success() diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index e0aecaf60..09bdd768e 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -21,7 +21,7 @@ class VerifySnmpStatus(AntaTest): - """Verifies whether the SNMP agent is enabled in a specified VRF. + """Verifies if the SNMP agent is enabled. Expected Results ---------------- @@ -37,7 +37,6 @@ class VerifySnmpStatus(AntaTest): ``` """ - description = "Verifies if the SNMP agent is enabled." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -50,15 +49,14 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpStatus.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]: - self.result.is_success() - else: - self.result.is_failure(f"SNMP agent disabled in vrf {self.inputs.vrf}") + if not (command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]): + self.result.is_failure(f"VRF: {self.inputs.vrf} - SNMP agent disabled") class VerifySnmpIPv4Acl(AntaTest): - """Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF. + """Verifies if the SNMP agent has IPv4 ACL(s) configured. Expected Results ---------------- @@ -75,7 +73,6 @@ class VerifySnmpIPv4Acl(AntaTest): ``` """ - description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)] @@ -90,23 +87,22 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpIPv4Acl.""" + self.result.is_success() command_output = self.instance_commands[0].json_output ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) if ipv4_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Incorrect SNMP IPv4 ACL(s) - Expected: {self.inputs.number} Actual: {ipv4_acl_number}") return not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if not_configured_acl: - self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") - else: - self.result.is_success() + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SNMP IPv4 ACL(s) not configured or active: {', '.join(not_configured_acl)}") class VerifySnmpIPv6Acl(AntaTest): - """Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF. + """Verifies if the SNMP agent has IPv6 ACL(s) configured. Expected Results ---------------- @@ -123,7 +119,6 @@ class VerifySnmpIPv6Acl(AntaTest): ``` """ - description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)] @@ -139,18 +134,17 @@ class Input(AntaTest.Input): def test(self) -> None: """Main test function for VerifySnmpIPv6Acl.""" command_output = self.instance_commands[0].json_output + self.result.is_success() ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) if ipv6_acl_number != self.inputs.number: - self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") + self.result.is_failure(f"VRF: {self.inputs.vrf} - Incorrect SNMP IPv6 ACL(s) - Expected: {self.inputs.number} Actual: {ipv6_acl_number}") return acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] if acl_not_configured: - self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}") - else: - self.result.is_success() + self.result.is_failure(f"VRF: {self.inputs.vrf} - Following SNMP IPv6 ACL(s) not configured or active: {', '.join(acl_not_configured)}") class VerifySnmpLocation(AntaTest): @@ -182,6 +176,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpLocation.""" + self.result.is_success() # Verifies the SNMP location is configured. if not (location := get_value(self.instance_commands[0].json_output, "location.location")): self.result.is_failure("SNMP location is not configured.") @@ -189,9 +184,7 @@ def test(self) -> None: # Verifies the expected SNMP location. if location != self.inputs.location: - self.result.is_failure(f"Expected `{self.inputs.location}` as the location, but found `{location}` instead.") - else: - self.result.is_success() + self.result.is_failure(f"Incorrect SNMP location - Expected: {self.inputs.location} Actual: {location}") class VerifySnmpContact(AntaTest): @@ -223,6 +216,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpContact.""" + self.result.is_success() # Verifies the SNMP contact is configured. if not (contact := get_value(self.instance_commands[0].json_output, "contact.contact")): self.result.is_failure("SNMP contact is not configured.") @@ -230,9 +224,7 @@ def test(self) -> None: # Verifies the expected SNMP contact. if contact != self.inputs.contact: - self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.") - else: - self.result.is_success() + self.result.is_failure(f"Incorrect SNMP contact - Expected: {self.inputs.contact} Actual: {contact}") class VerifySnmpPDUCounters(AntaTest): @@ -269,6 +261,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpPDUCounters.""" + self.result.is_success() snmp_pdus = self.inputs.pdus command_output = self.instance_commands[0].json_output @@ -281,13 +274,11 @@ def test(self) -> None: if not snmp_pdus: snmp_pdus = list(get_args(SnmpPdu)) - failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0} + failures = {pdu for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0} # Check if any failures - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}") + if failures: + self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters: {', '.join(sorted(failures))}") class VerifySnmpErrorCounters(AntaTest): @@ -323,6 +314,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySnmpErrorCounters.""" + self.result.is_success() error_counters = self.inputs.error_counters command_output = self.instance_commands[0].json_output @@ -335,13 +327,11 @@ def test(self) -> None: if not error_counters: error_counters = list(get_args(SnmpErrorCounter)) - error_counters_not_ok = {counter: value for counter in error_counters if (value := snmp_counters.get(counter))} + error_counters_not_ok = {counter for counter in error_counters if snmp_counters.get(counter)} # Check if any failures - if not error_counters_not_ok: - self.result.is_success() - else: - self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}") + if error_counters_not_ok: + self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters: {', '.join(sorted(error_counters_not_ok))}") class VerifySnmpHostLogging(AntaTest): diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py index 955aab0f0..2b9011c3b 100644 --- a/tests/units/anta_tests/test_services.py +++ b/tests/units/anta_tests/test_services.py @@ -25,7 +25,7 @@ "inputs": {"hostname": "s1-spine1"}, "expected": { "result": "failure", - "messages": ["Expected `s1-spine1` as the hostname, but found `s1-spine2` instead."], + "messages": ["Incorrect Hostname - Expected: s1-spine1 Actual: s1-spine2"], }, }, { @@ -88,7 +88,7 @@ }, "expected": { "result": "failure", - "messages": ["Server 10.14.0.10 (VRF: default, Priority: 0) - Not configured", "Server 10.14.0.21 (VRF: MGMT, Priority: 1) - Not configured"], + "messages": ["Server 10.14.0.10 VRF: default Priority: 0 - Not configured", "Server 10.14.0.21 VRF: MGMT Priority: 1 - Not configured"], }, }, { @@ -109,9 +109,9 @@ "expected": { "result": "failure", "messages": [ - "Server 10.14.0.1 (VRF: CS, Priority: 0) - Incorrect priority - Priority: 1", - "Server 10.14.0.11 (VRF: default, Priority: 0) - Not configured", - "Server 10.14.0.110 (VRF: MGMT, Priority: 0) - Not configured", + "Server 10.14.0.1 VRF: CS Priority: 0 - Incorrect priority - Priority: 1", + "Server 10.14.0.11 VRF: default Priority: 0 - Not configured", + "Server 10.14.0.110 VRF: MGMT Priority: 0 - Not configured", ], }, }, diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index 2c844c717..48c6bbb1d 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -36,14 +36,14 @@ "test": VerifySnmpStatus, "eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": True}], "inputs": {"vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf MGMT"]}, + "expected": {"result": "failure", "messages": ["VRF: MGMT - SNMP agent disabled"]}, }, { "name": "failure-disabled", "test": VerifySnmpStatus, "eos_data": [{"vrfs": {"snmpVrfs": ["default"]}, "enabled": False}], "inputs": {"vrf": "default"}, - "expected": {"result": "failure", "messages": ["SNMP agent disabled in vrf default"]}, + "expected": {"result": "failure", "messages": ["VRF: default - SNMP agent disabled"]}, }, { "name": "success", @@ -57,14 +57,14 @@ "test": VerifySnmpIPv4Acl, "eos_data": [{"ipAclList": {"aclList": []}}], "inputs": {"number": 1, "vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["Expected 1 SNMP IPv4 ACL(s) in vrf MGMT but got 0"]}, + "expected": {"result": "failure", "messages": ["VRF: MGMT - Incorrect SNMP IPv4 ACL(s) - Expected: 1 Actual: 0"]}, }, { "name": "failure-wrong-vrf", "test": VerifySnmpIPv4Acl, "eos_data": [{"ipAclList": {"aclList": [{"type": "Ip4Acl", "name": "ACL_IPV4_SNMP", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], "inputs": {"number": 1, "vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["SNMP IPv4 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV4_SNMP']"]}, + "expected": {"result": "failure", "messages": ["VRF: MGMT - Following SNMP IPv4 ACL(s) not configured or active: ACL_IPV4_SNMP"]}, }, { "name": "success", @@ -78,14 +78,14 @@ "test": VerifySnmpIPv6Acl, "eos_data": [{"ipv6AclList": {"aclList": []}}], "inputs": {"number": 1, "vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["Expected 1 SNMP IPv6 ACL(s) in vrf MGMT but got 0"]}, + "expected": {"result": "failure", "messages": ["VRF: MGMT - Incorrect SNMP IPv6 ACL(s) - Expected: 1 Actual: 0"]}, }, { "name": "failure-wrong-vrf", "test": VerifySnmpIPv6Acl, "eos_data": [{"ipv6AclList": {"aclList": [{"type": "Ip6Acl", "name": "ACL_IPV6_SNMP", "configuredVrfs": ["default"], "activeVrfs": ["default"]}]}}], "inputs": {"number": 1, "vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["SNMP IPv6 ACL(s) not configured or active in vrf MGMT: ['ACL_IPV6_SNMP']"]}, + "expected": {"result": "failure", "messages": ["VRF: MGMT - Following SNMP IPv6 ACL(s) not configured or active: ACL_IPV6_SNMP"]}, }, { "name": "success", @@ -109,7 +109,7 @@ "inputs": {"location": "New York"}, "expected": { "result": "failure", - "messages": ["Expected `New York` as the location, but found `Europe` instead."], + "messages": ["Incorrect SNMP location - Expected: New York Actual: Europe"], }, }, { @@ -148,7 +148,7 @@ "inputs": {"contact": "Bob@example.com"}, "expected": { "result": "failure", - "messages": ["Expected `Bob@example.com` as the contact, but found `Jon@example.com` instead."], + "messages": ["Incorrect SNMP contact - Expected: Bob@example.com Actual: Jon@example.com"], }, }, { @@ -227,7 +227,7 @@ "inputs": {}, "expected": { "result": "failure", - "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 0, 'inSetPdus': 0}"], + "messages": ["The following SNMP PDU counters are not found or have zero PDU counters: inGetPdus, inSetPdus"], }, }, { @@ -245,7 +245,7 @@ "inputs": {"pdus": ["inGetPdus", "outTrapPdus"]}, "expected": { "result": "failure", - "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 'Not Found', 'outTrapPdus': 'Not Found'}"], + "messages": ["The following SNMP PDU counters are not found or have zero PDU counters: inGetPdus, outTrapPdus"], }, }, { @@ -319,9 +319,7 @@ "inputs": {}, "expected": { "result": "failure", - "messages": [ - "The following SNMP error counters are not found or have non-zero error counters:\n{'inVersionErrs': 1, 'inParseErrs': 2, 'outBadValueErrs': 2}" - ], + "messages": ["The following SNMP error counters are not found or have non-zero error counters: inParseErrs, inVersionErrs, outBadValueErrs"], }, }, { From f3ccdfaee2c87736193d63efc89ba381a2b8d10a Mon Sep 17 00:00:00 2001 From: geetanjalimanegslab <96573243+geetanjalimanegslab@users.noreply.github.com> Date: Thu, 13 Feb 2025 23:41:32 +0530 Subject: [PATCH 04/13] refactor(anta.tests): Nicer result failure messages PTP, software test module (#1038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(anta.tests): Nicer result failure messages PTP, software test moduleĀ  * Updated docstrings and test failure message * Added unit tests for extensions test --------- Co-authored-by: Carl Baillargeon --- anta/tests/ptp.py | 31 +++---- anta/tests/software.py | 29 +++---- tests/units/anta_tests/test_ptp.py | 10 ++- tests/units/anta_tests/test_software.py | 106 ++++++++++++++++++++++-- 4 files changed, 128 insertions(+), 48 deletions(-) diff --git a/anta/tests/ptp.py b/anta/tests/ptp.py index e38d58ef8..5fcd8554b 100644 --- a/anta/tests/ptp.py +++ b/anta/tests/ptp.py @@ -17,7 +17,7 @@ class VerifyPtpModeStatus(AntaTest): - """Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC). + """Verifies that the device is configured as a PTP Boundary Clock. Expected Results ---------------- @@ -33,7 +33,6 @@ class VerifyPtpModeStatus(AntaTest): ``` """ - description = "Verifies that the device is configured as a PTP Boundary Clock." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -48,13 +47,13 @@ def test(self) -> None: return if ptp_mode != "ptpBoundaryClock": - self.result.is_failure(f"The device is not configured as a PTP Boundary Clock: '{ptp_mode}'") + self.result.is_failure(f"Not configured as a PTP Boundary Clock - Actual: {ptp_mode}") else: self.result.is_success() class VerifyPtpGMStatus(AntaTest): - """Verifies that the device is locked to a valid Precision Time Protocol (PTP) Grandmaster (GM). + """Verifies that the device is locked to a valid PTP Grandmaster. To test PTP failover, re-run the test with a secondary GMID configured. @@ -79,7 +78,6 @@ class Input(AntaTest.Input): gmid: str """Identifier of the Grandmaster to which the device should be locked.""" - description = "Verifies that the device is locked to a valid PTP Grandmaster." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -102,7 +100,7 @@ def test(self) -> None: class VerifyPtpLockStatus(AntaTest): - """Verifies that the device was locked to the upstream Precision Time Protocol (PTP) Grandmaster (GM) in the last minute. + """Verifies that the device was locked to the upstream PTP GM in the last minute. Expected Results ---------------- @@ -118,7 +116,6 @@ class VerifyPtpLockStatus(AntaTest): ``` """ - description = "Verifies that the device was locked to the upstream PTP GM in the last minute." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -136,13 +133,13 @@ def test(self) -> None: time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"] if time_difference >= threshold: - self.result.is_failure(f"The device lock is more than {threshold}s old: {time_difference}s") + self.result.is_failure(f"Lock is more than {threshold}s old - Actual: {time_difference}s") else: self.result.is_success() class VerifyPtpOffset(AntaTest): - """Verifies that the Precision Time Protocol (PTP) timing offset is within +/- 1000ns from the master clock. + """Verifies that the PTP timing offset is within +/- 1000ns from the master clock. Expected Results ---------------- @@ -158,7 +155,6 @@ class VerifyPtpOffset(AntaTest): ``` """ - description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)] @@ -167,9 +163,9 @@ class VerifyPtpOffset(AntaTest): def test(self) -> None: """Main test function for VerifyPtpOffset.""" threshold = 1000 - offset_interfaces: dict[str, list[int]] = {} + self.result.is_success() command_output = self.instance_commands[0].json_output - + offset_interfaces: dict[str, list[int]] = {} if not command_output["ptpMonitorData"]: self.result.is_skipped("PTP is not configured") return @@ -178,14 +174,12 @@ def test(self) -> None: if abs(interface["offsetFromMaster"]) > threshold: offset_interfaces.setdefault(interface["intf"], []).append(interface["offsetFromMaster"]) - if offset_interfaces: - self.result.is_failure(f"The device timing offset from master is greater than +/- {threshold}ns: {offset_interfaces}") - else: - self.result.is_success() + for interface, data in offset_interfaces.items(): + self.result.is_failure(f"Interface: {interface} - Timing offset from master is greater than +/- {threshold}ns: Actual: {', '.join(map(str, data))}") class VerifyPtpPortModeStatus(AntaTest): - """Verifies that all interfaces are in a valid Precision Time Protocol (PTP) state. + """Verifies the PTP interfaces state. The interfaces can be in one of the following state: Master, Slave, Passive, or Disabled. @@ -202,7 +196,6 @@ class VerifyPtpPortModeStatus(AntaTest): ``` """ - description = "Verifies the PTP interfaces state." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -227,4 +220,4 @@ def test(self) -> None: if not invalid_interfaces: self.result.is_success() else: - self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'") + self.result.is_failure(f"The following interface(s) are not in a valid PTP state: {', '.join(invalid_interfaces)}") diff --git a/anta/tests/software.py b/anta/tests/software.py index 113f2a4fc..84228ca3c 100644 --- a/anta/tests/software.py +++ b/anta/tests/software.py @@ -16,7 +16,7 @@ class VerifyEOSVersion(AntaTest): - """Verifies that the device is running one of the allowed EOS version. + """Verifies the EOS version of the device. Expected Results ---------------- @@ -34,7 +34,6 @@ class VerifyEOSVersion(AntaTest): ``` """ - description = "Verifies the EOS version of the device." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] @@ -48,14 +47,13 @@ class Input(AntaTest.Input): def test(self) -> None: """Main test function for VerifyEOSVersion.""" command_output = self.instance_commands[0].json_output - if command_output["version"] in self.inputs.versions: - self.result.is_success() - else: - self.result.is_failure(f'device is running version "{command_output["version"]}" not in expected versions: {self.inputs.versions}') + self.result.is_success() + if command_output["version"] not in self.inputs.versions: + self.result.is_failure(f"EOS version mismatch - Actual: {command_output['version']} not in Expected: {', '.join(self.inputs.versions)}") class VerifyTerminAttrVersion(AntaTest): - """Verifies that he device is running one of the allowed TerminAttr version. + """Verifies the TerminAttr version of the device. Expected Results ---------------- @@ -73,7 +71,6 @@ class VerifyTerminAttrVersion(AntaTest): ``` """ - description = "Verifies the TerminAttr version of the device." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] @@ -87,11 +84,10 @@ class Input(AntaTest.Input): def test(self) -> None: """Main test function for VerifyTerminAttrVersion.""" command_output = self.instance_commands[0].json_output + self.result.is_success() command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"] - if command_output_data in self.inputs.versions: - self.result.is_success() - else: - self.result.is_failure(f"device is running TerminAttr version {command_output_data} and is not in the allowed list: {self.inputs.versions}") + if command_output_data not in self.inputs.versions: + self.result.is_failure(f"TerminAttr version mismatch - Actual: {command_output_data} not in Expected: {', '.join(self.inputs.versions)}") class VerifyEOSExtensions(AntaTest): @@ -120,6 +116,7 @@ class VerifyEOSExtensions(AntaTest): def test(self) -> None: """Main test function for VerifyEOSExtensions.""" boot_extensions = [] + self.result.is_success() show_extensions_command_output = self.instance_commands[0].json_output show_boot_extensions_command_output = self.instance_commands[1].json_output installed_extensions = [ @@ -131,7 +128,7 @@ def test(self) -> None: boot_extensions.append(formatted_extension) installed_extensions.sort() boot_extensions.sort() - if installed_extensions == boot_extensions: - self.result.is_success() - else: - self.result.is_failure(f"Missing EOS extensions: installed {installed_extensions} / configured: {boot_extensions}") + if installed_extensions != boot_extensions: + str_installed_extensions = ", ".join(installed_extensions) if installed_extensions else "Not found" + str_boot_extensions = ", ".join(boot_extensions) if boot_extensions else "Not found" + self.result.is_failure(f"EOS extensions mismatch - Installed: {str_installed_extensions}, Configured: {str_boot_extensions}") diff --git a/tests/units/anta_tests/test_ptp.py b/tests/units/anta_tests/test_ptp.py index 112e33475..91f7f0350 100644 --- a/tests/units/anta_tests/test_ptp.py +++ b/tests/units/anta_tests/test_ptp.py @@ -39,7 +39,7 @@ "test": VerifyPtpModeStatus, "eos_data": [{"ptpMode": "ptpDisabled", "ptpIntfSummaries": {}}], "inputs": None, - "expected": {"result": "failure", "messages": ["The device is not configured as a PTP Boundary Clock: 'ptpDisabled'"]}, + "expected": {"result": "failure", "messages": ["Not configured as a PTP Boundary Clock - Actual: ptpDisabled"]}, }, { "name": "skipped", @@ -158,7 +158,7 @@ } ], "inputs": None, - "expected": {"result": "failure", "messages": ["The device lock is more than 60s old: 157s"]}, + "expected": {"result": "failure", "messages": ["Lock is more than 60s old - Actual: 157s"]}, }, { "name": "skipped", @@ -236,7 +236,9 @@ "inputs": None, "expected": { "result": "failure", - "messages": [("The device timing offset from master is greater than +/- 1000ns: {'Ethernet27/1': [1200, -1300]}")], + "messages": [ + "Interface: Ethernet27/1 - Timing offset from master is greater than +/- 1000ns: Actual: 1200, -1300", + ], }, }, { @@ -335,6 +337,6 @@ } ], "inputs": None, - "expected": {"result": "failure", "messages": ["The following interface(s) are not in a valid PTP state: '['Ethernet53', 'Ethernet1']'"]}, + "expected": {"result": "failure", "messages": ["The following interface(s) are not in a valid PTP state: Ethernet53, Ethernet1"]}, }, ] diff --git a/tests/units/anta_tests/test_software.py b/tests/units/anta_tests/test_software.py index f0e5ea94d..0bf88b83c 100644 --- a/tests/units/anta_tests/test_software.py +++ b/tests/units/anta_tests/test_software.py @@ -35,7 +35,7 @@ }, ], "inputs": {"versions": ["4.27.1F"]}, - "expected": {"result": "failure", "messages": ["device is running version \"4.27.0F\" not in expected versions: ['4.27.1F']"]}, + "expected": {"result": "failure", "messages": ["EOS version mismatch - Actual: 4.27.0F not in Expected: 4.27.1F"]}, }, { "name": "success", @@ -77,9 +77,8 @@ }, ], "inputs": {"versions": ["v1.17.1", "v1.18.1"]}, - "expected": {"result": "failure", "messages": ["device is running TerminAttr version v1.17.0 and is not in the allowed list: ['v1.17.1', 'v1.18.1']"]}, + "expected": {"result": "failure", "messages": ["TerminAttr version mismatch - Actual: v1.17.0 not in Expected: v1.17.1, v1.18.1"]}, }, - # TODO: add a test with a real extension? { "name": "success-no-extensions", "test": VerifyEOSExtensions, @@ -91,11 +90,30 @@ "expected": {"result": "success"}, }, { - "name": "success-empty-extension", + "name": "success-extensions", "test": VerifyEOSExtensions, "eos_data": [ - {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]}, - {"extensions": [""]}, + { + "extensions": { + "AristaCloudGateway-1.0.1-1.swix": { + "version": "1.0.1", + "release": "1", + "presence": "present", + "status": "installed", + "boot": True, + "numPackages": 1, + "error": False, + "vendor": "", + "summary": "Arista Cloud Connect", + "installedSize": 60532424, + "packages": {"AristaCloudGateway-1.0.1-1.x86_64.rpm": {"version": "1.0.1", "release": "1"}}, + "description": "An extension for Arista Cloud Connect gateway", + "affectedAgents": [], + "agentsToRestart": [], + }, + } + }, + {"extensions": ["AristaCloudGateway-1.0.1-1.swix"]}, ], "inputs": None, "expected": {"result": "success"}, @@ -104,10 +122,80 @@ "name": "failure", "test": VerifyEOSExtensions, "eos_data": [ - {"extensions": {}, "extensionStoredDir": "flash:", "warnings": ["No extensions are available"]}, - {"extensions": ["dummy"]}, + { + "extensions": { + "AristaCloudGateway-1.0.1-1.swix": { + "version": "1.0.1", + "release": "1", + "presence": "present", + "status": "installed", + "boot": False, + "numPackages": 1, + "error": False, + "vendor": "", + "summary": "Arista Cloud Connect", + "installedSize": 60532424, + "packages": {"AristaCloudGateway-1.0.1-1.x86_64.rpm": {"version": "1.0.1", "release": "1"}}, + "description": "An extension for Arista Cloud Connect gateway", + "affectedAgents": [], + "agentsToRestart": [], + }, + } + }, + {"extensions": []}, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["EOS extensions mismatch - Installed: AristaCloudGateway-1.0.1-1.swix, Configured: Not found"]}, + }, + { + "name": "failure-multiple-extensions", + "test": VerifyEOSExtensions, + "eos_data": [ + { + "extensions": { + "AristaCloudGateway-1.0.1-1.swix": { + "version": "1.0.1", + "release": "1", + "presence": "present", + "status": "installed", + "boot": False, + "numPackages": 1, + "error": False, + "vendor": "", + "summary": "Arista Cloud Connect", + "installedSize": 60532424, + "packages": {"AristaCloudGateway-1.0.1-1.x86_64.rpm": {"version": "1.0.1", "release": "1"}}, + "description": "An extension for Arista Cloud Connect gateway", + "affectedAgents": [], + "agentsToRestart": [], + }, + "EOS-4.33.0F-NDRSensor.swix": { + "version": "4.33.0", + "release": "39050855.4330F", + "presence": "present", + "status": "notInstalled", + "boot": True, + "numPackages": 9, + "error": False, + "statusDetail": "No RPMs are compatible with current EOS version.", + "vendor": "", + "summary": "NDR sensor", + "installedSize": 0, + "packages": {}, + "description": "NDR sensor provides libraries to generate flow activity records using DPI\nmetadata and IPFIX flow records.", + "affectedAgents": [], + "agentsToRestart": [], + }, + } + }, + {"extensions": ["AristaCloudGateway-1.0.1-1.swix", "EOS-4.33.0F-NDRSensor.swix"]}, ], "inputs": None, - "expected": {"result": "failure", "messages": ["Missing EOS extensions: installed [] / configured: ['dummy']"]}, + "expected": { + "result": "failure", + "messages": [ + "EOS extensions mismatch - Installed: AristaCloudGateway-1.0.1-1.swix, Configured: AristaCloudGateway-1.0.1-1.swix, EOS-4.33.0F-NDRSensor.swix" + ], + }, }, ] From b54382ab37704ba43f8c1ea7222fc0bb26019755 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Wed, 19 Feb 2025 15:04:54 +0100 Subject: [PATCH 05/13] ci: Address Sonarcloud action deprecation warnings (#1048) ci: Address warnings --- .github/workflows/sonar.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 81db36e58..7ec789639 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup Python uses: actions/setup-python@v5 with: @@ -30,7 +30,7 @@ jobs: - name: "Run pytest via tox for ${{ matrix.python }}" run: tox - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master + uses: SonarSource/sonarqube-scan-action@v5.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 7c9bef421fd913571d45c760ecabc66e62da1c2e Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 20 Feb 2025 04:44:52 +0530 Subject: [PATCH 06/13] refactor(anta.tests): Refactor VerifyISISSegmentRoutingTunnels test case (#1037) --- anta/input_models/routing/isis.py | 80 ++++++++++++++++++- anta/tests/routing/isis.py | 57 ++++--------- tests/units/anta_tests/routing/test_isis.py | 20 ++--- tests/units/input_models/routing/test_isis.py | 35 +++++++- 4 files changed, 138 insertions(+), 54 deletions(-) diff --git a/anta/input_models/routing/isis.py b/anta/input_models/routing/isis.py index efeefe604..c0e264997 100644 --- a/anta/input_models/routing/isis.py +++ b/anta/input_models/routing/isis.py @@ -5,7 +5,7 @@ from __future__ import annotations -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv4Network from typing import Any, Literal from warnings import warn @@ -122,3 +122,81 @@ def __init__(self, **data: Any) -> None: # noqa: ANN401 stacklevel=2, ) super().__init__(**data) + + +class Tunnel(BaseModel): + """Model for a IS-IS SR tunnel.""" + + model_config = ConfigDict(extra="forbid") + endpoint: IPv4Network + """Endpoint of the tunnel.""" + vias: list[TunnelPath] | None = None + """Optional list of paths to reach the endpoint.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the Tunnel for reporting.""" + return f"Endpoint: {self.endpoint}" + + +class TunnelPath(BaseModel): + """Model for a IS-IS tunnel path.""" + + model_config = ConfigDict(extra="forbid") + nexthop: IPv4Address | None = None + """Nexthop of the tunnel.""" + type: Literal["ip", "tunnel"] | None = None + """Type of the tunnel.""" + interface: Interface | None = None + """Interface of the tunnel.""" + tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None + """Computation method of the tunnel.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the TunnelPath for reporting.""" + base_string = "" + if self.nexthop: + base_string += f" Next-hop: {self.nexthop}" + if self.type: + base_string += f" Type: {self.type}" + if self.interface: + base_string += f" Interface: {self.interface}" + if self.tunnel_id: + base_string += f" Tunnel ID: {self.tunnel_id}" + + return base_string.lstrip() + + +class Entry(Tunnel): # pragma: no cover + """Alias for the Tunnel model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the Tunnel model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the Entry class, emitting a deprecation warning.""" + warn( + message="Entry model is deprecated and will be removed in ANTA v2.0.0. Use the Tunnel model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class Vias(TunnelPath): # pragma: no cover + """Alias for the TunnelPath model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the TunnelPath model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the Vias class, emitting a deprecation warning.""" + warn( + message="Vias model is deprecated and will be removed in ANTA v2.0.0. Use the TunnelPath model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py index 47803e3a2..70574e51c 100644 --- a/anta/tests/routing/isis.py +++ b/anta/tests/routing/isis.py @@ -7,13 +7,11 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address, IPv4Network -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar -from pydantic import BaseModel, field_validator +from pydantic import field_validator -from anta.custom_types import Interface -from anta.input_models.routing.isis import InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface +from anta.input_models.routing.isis import Entry, InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface, Tunnel, TunnelPath from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_item, get_value @@ -391,34 +389,9 @@ class VerifyISISSegmentRoutingTunnels(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyISISSegmentRoutingTunnels test.""" - entries: list[Entry] + entries: list[Tunnel] """List of tunnels to check on device.""" - - class Entry(BaseModel): - """Definition of a tunnel entry.""" - - endpoint: IPv4Network - """Endpoint IP of the tunnel.""" - vias: list[Vias] | None = None - """Optional list of path to reach endpoint.""" - - class Vias(BaseModel): - """Definition of a tunnel path.""" - - nexthop: IPv4Address | None = None - """Nexthop of the tunnel. If None, then it is not tested. Default: None""" - type: Literal["ip", "tunnel"] | None = None - """Type of the tunnel. If None, then it is not tested. Default: None""" - interface: Interface | None = None - """Interface of the tunnel. If None, then it is not tested. Default: None""" - tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None - """Computation method of the tunnel. If None, then it is not tested. Default: None""" - - def _eos_entry_lookup(self, search_value: IPv4Network, entries: dict[str, Any], search_key: str = "endpoint") -> dict[str, Any] | None: - return next( - (entry_value for entry_id, entry_value in entries.items() if str(entry_value[search_key]) == str(search_value)), - None, - ) + Entry: ClassVar[type[Entry]] = Entry @AntaTest.anta_test def test(self) -> None: @@ -427,29 +400,31 @@ def test(self) -> None: This method performs the main test logic for verifying ISIS Segment Routing tunnels. It checks the command output, initiates defaults, and performs various checks on the tunnels. """ - command_output = self.instance_commands[0].json_output self.result.is_success() + command_output = self.instance_commands[0].json_output if len(command_output["entries"]) == 0: - self.result.is_skipped("IS-IS-SR is not running on device.") + self.result.is_skipped("IS-IS-SR not configured") return for input_entry in self.inputs.entries: - eos_entry = self._eos_entry_lookup(search_value=input_entry.endpoint, entries=command_output["entries"]) - if eos_entry is None: - self.result.is_failure(f"Tunnel to {input_entry.endpoint!s} is not found.") - elif input_entry.vias is not None: + entries = list(command_output["entries"].values()) + if (eos_entry := get_item(entries, "endpoint", str(input_entry.endpoint))) is None: + self.result.is_failure(f"{input_entry} - Tunnel not found") + continue + + if input_entry.vias is not None: for via_input in input_entry.vias: via_search_result = any(self._via_matches(via_input, eos_via) for eos_via in eos_entry["vias"]) if not via_search_result: - self.result.is_failure(f"Tunnel to {input_entry.endpoint!s} is incorrect.") + self.result.is_failure(f"{input_entry} {via_input} - Tunnel is incorrect") - def _via_matches(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_via: dict[str, Any]) -> bool: + def _via_matches(self, via_input: TunnelPath, eos_via: dict[str, Any]) -> bool: """Check if the via input matches the eos via. Parameters ---------- - via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias + via_input : TunnelPath The input via to check. eos_via : dict[str, Any] The EOS via to compare against. diff --git a/tests/units/anta_tests/routing/test_isis.py b/tests/units/anta_tests/routing/test_isis.py index 733f5710b..aaa77b247 100644 --- a/tests/units/anta_tests/routing/test_isis.py +++ b/tests/units/anta_tests/routing/test_isis.py @@ -1812,7 +1812,7 @@ }, "expected": { "result": "skipped", - "messages": ["IS-IS-SR is not running on device."], + "messages": ["IS-IS-SR not configured"], }, }, { @@ -1841,7 +1841,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is not found."], + "messages": ["Endpoint: 1.0.0.122/32 - Tunnel not found"], }, }, { @@ -1922,7 +1922,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.13/32 is incorrect."], + "messages": ["Endpoint: 1.0.0.13/32 Type: tunnel - Tunnel is incorrect"], }, }, { @@ -2010,7 +2010,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is incorrect."], + "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.2 Type: ip Interface: Ethernet1 - Tunnel is incorrect"], }, }, { @@ -2098,7 +2098,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is incorrect."], + "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.1 Type: ip Interface: Ethernet4 - Tunnel is incorrect"], }, }, { @@ -2186,7 +2186,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is incorrect."], + "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.2 Type: ip Interface: Ethernet1 - Tunnel is incorrect"], }, }, { @@ -2251,7 +2251,7 @@ "vias": [ { "type": "tunnel", - "tunnelId": {"type": "TI-LFA", "index": 4}, + "tunnelId": {"type": "unset", "index": 4}, "labels": ["3"], } ], @@ -2266,14 +2266,14 @@ { "endpoint": "1.0.0.111/32", "vias": [ - {"type": "tunnel", "tunnel_id": "unset"}, + {"type": "tunnel", "tunnel_id": "ti-lfa"}, ], }, ] }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.111/32 is incorrect."], + "messages": ["Endpoint: 1.0.0.111/32 Type: tunnel Tunnel ID: ti-lfa - Tunnel is incorrect"], }, }, { @@ -2294,7 +2294,7 @@ }, "expected": { "result": "skipped", - "messages": ["IS-IS-SR is not running on device."], + "messages": ["IS-IS-SR not configured"], }, }, ] diff --git a/tests/units/input_models/routing/test_isis.py b/tests/units/input_models/routing/test_isis.py index f22bfa6fd..eeef051a4 100644 --- a/tests/units/input_models/routing/test_isis.py +++ b/tests/units/input_models/routing/test_isis.py @@ -5,15 +5,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import pytest from pydantic import ValidationError +from anta.input_models.routing.isis import ISISInstance, TunnelPath from anta.tests.routing.isis import VerifyISISSegmentRoutingAdjacencySegments, VerifyISISSegmentRoutingDataplane if TYPE_CHECKING: - from anta.input_models.routing.isis import ISISInstance + from ipaddress import IPv4Address + + from anta.custom_types import Interface class TestVerifyISISSegmentRoutingAdjacencySegmentsInput: @@ -68,3 +71,31 @@ def test_invalid(self, instances: list[ISISInstance]) -> None: """Test VerifyISISSegmentRoutingDataplane.Input invalid inputs.""" with pytest.raises(ValidationError): VerifyISISSegmentRoutingDataplane.Input(instances=instances) + + +class TestTunnelPath: + """Test anta.input_models.routing.isis.TestTunnelPath.""" + + # pylint: disable=too-few-public-methods + + @pytest.mark.parametrize( + ("nexthop", "type", "interface", "tunnel_id", "expected"), + [ + pytest.param("1.1.1.1", None, None, None, "Next-hop: 1.1.1.1", id="nexthop"), + pytest.param(None, "ip", None, None, "Type: ip", id="type"), + pytest.param(None, None, "Et1", None, "Interface: Ethernet1", id="interface"), + pytest.param(None, None, None, "TI-LFA", "Tunnel ID: TI-LFA", id="tunnel_id"), + pytest.param("1.1.1.1", "ip", "Et1", "TI-LFA", "Next-hop: 1.1.1.1 Type: ip Interface: Ethernet1 Tunnel ID: TI-LFA", id="all"), + pytest.param(None, None, None, None, "", id="None"), + ], + ) + def test_valid__str__( + self, + nexthop: IPv4Address | None, + type: Literal["ip", "tunnel"] | None, # noqa: A002 + interface: Interface | None, + tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None, + expected: str, + ) -> None: + """Test TunnelPath __str__.""" + assert str(TunnelPath(nexthop=nexthop, type=type, interface=interface, tunnel_id=tunnel_id)) == expected From ecce5e59f10086ed312b9292f3a85c7ea9a2315e Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Fri, 21 Feb 2025 09:19:43 +0100 Subject: [PATCH 07/13] fix(anta.cli): Make CSV output file option mandatory (#1052) * Fix(anta.cli): Make cvs outputfile mandatory * Doc: Fx script logic * Update CSV docstring --------- Co-authored-by: Carl Baillargeon --- .pre-commit-config.yaml | 31 +- anta/cli/nrfu/__init__.py | 5 +- anta/cli/nrfu/commands.py | 9 +- docs/cli/nrfu.md | 58 +--- docs/getting-started.md | 6 +- docs/imgs/anta_debug_help.svg | 100 ++++++ docs/imgs/anta_help.svg | 139 ++++++++ docs/imgs/anta_nrfu_csv_help.svg | 91 ++++++ docs/imgs/anta_nrfu_help.svg | 303 ++++++++++++++++++ docs/imgs/anta_nrfu_json_help.svg | 91 ++++++ docs/imgs/anta_nrfu_mdreport_help.svg | 91 ++++++ docs/imgs/anta_nrfu_table_help.svg | 87 +++++ docs/imgs/anta_nrfu_text_help.svg | 83 +++++ docs/imgs/anta_nrfu_tplreport_help.svg | 99 ++++++ docs/scripts/__init__.py | 4 - docs/scripts/generate_doc_snippets.py | 28 ++ .../{generate_svg.py => generate_snippet.py} | 58 ++-- docs/snippets/anta_debug_help.txt | 11 + docs/snippets/anta_help.txt | 3 +- docs/snippets/anta_nrfu_csv_help.txt | 9 + docs/snippets/anta_nrfu_help.txt | 1 + docs/snippets/anta_nrfu_json_help.txt | 11 + docs/snippets/anta_nrfu_mdreport_help.txt | 9 + docs/snippets/anta_nrfu_table_help.txt | 8 + docs/snippets/anta_nrfu_text_help.txt | 7 + docs/snippets/anta_nrfu_tplreport_help.txt | 11 + 26 files changed, 1253 insertions(+), 100 deletions(-) create mode 100644 docs/imgs/anta_debug_help.svg create mode 100644 docs/imgs/anta_help.svg create mode 100644 docs/imgs/anta_nrfu_csv_help.svg create mode 100644 docs/imgs/anta_nrfu_help.svg create mode 100644 docs/imgs/anta_nrfu_json_help.svg create mode 100644 docs/imgs/anta_nrfu_mdreport_help.svg create mode 100644 docs/imgs/anta_nrfu_table_help.svg create mode 100644 docs/imgs/anta_nrfu_text_help.svg create mode 100644 docs/imgs/anta_nrfu_tplreport_help.svg delete mode 100644 docs/scripts/__init__.py create mode 100755 docs/scripts/generate_doc_snippets.py rename docs/scripts/{generate_svg.py => generate_snippet.py} (62%) mode change 100644 => 100755 create mode 100644 docs/snippets/anta_debug_help.txt create mode 100644 docs/snippets/anta_nrfu_csv_help.txt create mode 100644 docs/snippets/anta_nrfu_json_help.txt create mode 100644 docs/snippets/anta_nrfu_mdreport_help.txt create mode 100644 docs/snippets/anta_nrfu_table_help.txt create mode 100644 docs/snippets/anta_nrfu_text_help.txt create mode 100644 docs/snippets/anta_nrfu_tplreport_help.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbbb977dd..a2b6e5d49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,16 +43,16 @@ repos: - --allow-past-years - --fuzzy-match-generates-todo - --comment-style - - '' + - "" - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.6 hooks: - - id: ruff - name: Run Ruff linter - args: [ --fix ] - - id: ruff-format - name: Run Ruff formatter + - id: ruff + name: Run Ruff linter + args: [--fix] + - id: ruff-format + name: Run Ruff formatter - repo: https://github.com/pycqa/pylint rev: "v3.3.4" @@ -62,9 +62,9 @@ repos: description: This hook runs pylint. types: [python] args: - - -rn # Only display messages - - -sn # Don't display the score - - --rcfile=pyproject.toml # Link to config file + - -rn # Only display messages + - -sn # Don't display the score + - --rcfile=pyproject.toml # Link to config file additional_dependencies: - anta[cli] - types-PyYAML @@ -123,5 +123,14 @@ repos: pass_filenames: false additional_dependencies: - anta[cli] - # TODO: next can go once we have it added to anta properly - - numpydoc + - id: doc-snippets + name: Generate doc snippets + entry: >- + sh -c "docs/scripts/generate_doc_snippets.py" + language: python + types: [python] + files: anta/cli/ + verbose: true + pass_filenames: false + additional_dependencies: + - anta[cli] diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index a6f76c4cd..6dc912dcc 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -42,9 +42,10 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: if "--help" not in args: raise - # remove the required params so that help can display + # Fake presence of the required params so that help can display for param in self.params: - param.required = False + if param.required: + param.value_is_missing = lambda value: False # type: ignore[method-assign] # noqa: ARG005 return super().parse_args(ctx, args) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index d1a72a01f..ed0f43244 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -45,7 +45,10 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non help="Path to save report as a JSON file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: - """ANTA command to check network state with JSON results.""" + """ANTA command to check network state with JSON results. + + If no `--output` is specified, the output is printed to stdout. + """ run_tests(ctx) print_json(ctx, output=output) exit_with_code(ctx) @@ -72,11 +75,11 @@ def text(ctx: click.Context) -> None: path_type=pathlib.Path, ), show_envvar=True, - required=False, + required=True, help="Path to save report as a CSV file", ) def csv(ctx: click.Context, csv_output: pathlib.Path) -> None: - """ANTA command to check network states with CSV result.""" + """ANTA command to check network state with CSV report.""" run_tests(ctx) save_to_csv(ctx, csv_file=csv_output) exit_with_code(ctx) diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index cb008a327..9e06f3645 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -48,12 +48,7 @@ The `text` subcommand provides a straightforward text report for each test execu ### Command overview ```bash -Usage: anta nrfu text [OPTIONS] - - ANTA command to check network states with text result. - -Options: - --help Show this message and exit. +--8<-- "anta_nrfu_text_help.txt" ``` ### Example @@ -71,13 +66,7 @@ The `table` command under the `anta nrfu` namespace offers a clear and organized ### Command overview ```bash -Usage: anta nrfu table [OPTIONS] - - ANTA command to check network states with table result. - -Options: - --group-by [device|test] Group result by test or device. - --help Show this message and exit. +--8<-- "anta_nrfu_table_help.txt" ``` The `--group-by` option show a summarized view of the test results per host or per test. @@ -125,15 +114,7 @@ The JSON rendering command in NRFU testing will generate an output of all test r ### Command overview ```bash -anta nrfu json --help -Usage: anta nrfu json [OPTIONS] - - ANTA command to check network state with JSON result. - -Options: - -o, --output FILE Path to save report as a JSON file [env var: - ANTA_NRFU_JSON_OUTPUT] - --help Show this message and exit. +--8<-- "anta_nrfu_json_help.txt" ``` The `--output` option allows you to save the JSON report as a file. If specified, no output will be displayed in the terminal. This is useful for further processing or integration with other tools. @@ -153,15 +134,7 @@ The `csv` command in NRFU testing is useful for generating a CSV file with all t ### Command overview ```bash -anta nrfu csv --help -Usage: anta nrfu csv [OPTIONS] - - ANTA command to check network states with CSV result. - -Options: - --csv-output FILE Path to save report as a CSV file [env var: - ANTA_NRFU_CSV_CSV_OUTPUT] - --help Show this message and exit. +--8<-- "anta_nrfu_csv_help.txt" ``` ### Example @@ -175,16 +148,7 @@ The `md-report` command in NRFU testing generates a comprehensive Markdown repor ### Command overview ```bash -anta nrfu md-report --help - -Usage: anta nrfu md-report [OPTIONS] - - ANTA command to check network state with Markdown report. - -Options: - --md-output FILE Path to save the report as a Markdown file [env var: - ANTA_NRFU_MD_REPORT_MD_OUTPUT; required] - --help Show this message and exit. +--8<-- "anta_nrfu_mdreport_help.txt" ``` ### Example @@ -198,17 +162,7 @@ ANTA offers a CLI option for creating custom reports. This leverages the Jinja2 ### Command overview ```bash -anta nrfu tpl-report --help -Usage: anta nrfu tpl-report [OPTIONS] - - ANTA command to check network state with templated report - -Options: - -tpl, --template FILE Path to the template to use for the report [env var: - ANTA_NRFU_TPL_REPORT_TEMPLATE; required] - -o, --output FILE Path to save report as a file [env var: - ANTA_NRFU_TPL_REPORT_OUTPUT] - --help Show this message and exit. +--8<-- "anta_nrfu_tplreport_help.txt" ``` The `--template` option is used to specify the Jinja2 template file for generating the custom report. diff --git a/docs/getting-started.md b/docs/getting-started.md index b36ea74c7..878e04be7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -86,12 +86,14 @@ This entrypoint has multiple options to manage test coverage and reporting. --8<-- "anta_help.txt" ``` +To run the NRFU, you need to select an output format amongst [`csv`, `json`, `md-report`, `table`, `text`, `tpl-report`]. + +For a first usage, `table` is recommended. By default all test results for all devices are rendered but it can be changed to a report per test case or per host + ```bash --8<-- "anta_nrfu_help.txt" ``` -To run the NRFU, you need to select an output format amongst ["json", "table", "text", "tpl-report"]. For a first usage, `table` is recommended. By default all test results for all devices are rendered but it can be changed to a report per test case or per host - !!! Note The following examples shows how to pass all the CLI options. diff --git a/docs/imgs/anta_debug_help.svg b/docs/imgs/anta_debug_help.svg new file mode 100644 index 000000000..7c8f271ca --- /dev/null +++ b/docs/imgs/anta_debug_help.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta debug --help + + + + + + + + + + Usage: anta debug [OPTIONS] COMMAND [ARGS]... + +  Commands to execute EOS commands on remote devices. + +Options: +  --help  Show this message and exit. + +Commands: +  run-cmd       Run arbitrary command to an ANTA device. +  run-template  Run arbitrary templated command to an ANTA device. + + + + + diff --git a/docs/imgs/anta_help.svg b/docs/imgs/anta_help.svg new file mode 100644 index 000000000..8a8f8f37b --- /dev/null +++ b/docs/imgs/anta_help.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta --help + + + + + + + + + Usage: anta [OPTIONS] COMMAND [ARGS]... + +   Arista Network Test Automation (ANTA) CLI. + + Options: +   --help                          Show this message and exit. +   --version                       Show the version and exit. +   --log-file FILE                 Send the logs to a file. If logging level is +                                   DEBUG, only INFO or higher will be sent to +                                   stdout.  [env var: ANTA_LOG_FILE] +   -l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] +                                   ANTA logging level  [env var: +                                   ANTA_LOG_LEVEL; default: INFO] + + Commands: +   check  Commands to validate configuration files. +   debug  Commands to execute EOS commands on remote devices. +   exec   Commands to execute various scripts on EOS devices. +   get    Commands to get information from or generate inventories. +   nrfu   Run ANTA tests on selected inventory devices. + + + + + diff --git a/docs/imgs/anta_nrfu_csv_help.svg b/docs/imgs/anta_nrfu_csv_help.svg new file mode 100644 index 000000000..8657d5538 --- /dev/null +++ b/docs/imgs/anta_nrfu_csv_help.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu csv --help + + + + + + + + + + Usage: anta nrfu csv [OPTIONS] + +  ANTA command to check network states with CSV result. + +Options: +  --csv-output FILE  Path to save report as a CSV file  [env var: +                     ANTA_NRFU_CSV_CSV_OUTPUT] +  --help             Show this message and exit. + + + + + diff --git a/docs/imgs/anta_nrfu_help.svg b/docs/imgs/anta_nrfu_help.svg new file mode 100644 index 000000000..d687be309 --- /dev/null +++ b/docs/imgs/anta_nrfu_help.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu --help + + + + + + + + + + Usage: anta nrfu [OPTIONS] COMMAND [ARGS]... + +  Run ANTA tests on selected inventory devices. + +Options: +  -u, --username TEXT             Username to connect to EOS  [env var: +                                  ANTA_USERNAME; required] +  -p, --password TEXT             Password to connect to EOS that must be +                                  provided. It can be prompted using '-- +                                  prompt' option.  [env var: ANTA_PASSWORD] +  --enable-password TEXT          Password to access EOS Privileged EXEC mode. +                                  It can be prompted using '--prompt' option. +                                  Requires '--enable' option.  [env var: +                                  ANTA_ENABLE_PASSWORD] +  --enable                        Some commands may require EOS Privileged +                                  EXEC mode. This option tries to access this +                                  mode before sending a command to the device. +[env var: ANTA_ENABLE] +  -P, --prompt                    Prompt for passwords if they are not +                                  provided.  [env var: ANTA_PROMPT] +  --timeout FLOAT                 Global API timeout. This value will be used +                                  for all devices.  [env var: ANTA_TIMEOUT; +                                  default: 30.0] +  --insecure                      Disable SSH Host Key validation.  [env var: +                                  ANTA_INSECURE] +  --disable-cache                 Disable cache globally.  [env var: +                                  ANTA_DISABLE_CACHE] +  -i, --inventory FILE            Path to the inventory YAML file.  [env var: +                                  ANTA_INVENTORY; required] +  --tags TEXT                     List of tags using comma as separator: +                                  tag1,tag2,tag3.  [env var: ANTA_TAGS] +  -c, --catalog FILE              Path to the test catalog file  [env var: +                                  ANTA_CATALOG; required] +  --catalog-format [yaml|json]    Format of the catalog file, either 'yaml' or +'json'[env var: ANTA_CATALOG_FORMAT] +  -d, --device TEXT               Run tests on a specific device. Can be +                                  provided multiple times. +  -t, --test TEXT                 Run a specific test. Can be provided +                                  multiple times. +  --ignore-status                 Exit code will always be 0.  [env var: +                                  ANTA_NRFU_IGNORE_STATUS] +  --ignore-error                  Exit code will be 0 if all tests succeeded +                                  or 1 if any test failed.  [env var: +                                  ANTA_NRFU_IGNORE_ERROR] +  --hide [success|failure|error|skipped] +                                  Hide results by type: success / failure / +                                  error / skipped'. +  --dry-run                       Run anta nrfu command but stop before +                                  starting to execute the tests. Considers all +                                  devices as connected.  [env var: +                                  ANTA_NRFU_DRY_RUN] +  --help                          Show this message and exit. + +Commands: +  csv         ANTA command to check network states with CSV result. +  json        ANTA command to check network state with JSON results. +  md-report   ANTA command to check network state with Markdown report. +  table       ANTA command to check network state with table results. +  text        ANTA command to check network state with text results. +  tpl-report  ANTA command to check network state with templated report. + + + + + diff --git a/docs/imgs/anta_nrfu_json_help.svg b/docs/imgs/anta_nrfu_json_help.svg new file mode 100644 index 000000000..f546ace15 --- /dev/null +++ b/docs/imgs/anta_nrfu_json_help.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu json --help + + + + + + + + + + Usage: anta nrfu json [OPTIONS] + +  ANTA command to check network state with JSON results. + +Options: +  -o, --output FILE  Path to save report as a JSON file  [env var: +                     ANTA_NRFU_JSON_OUTPUT] +  --help             Show this message and exit. + + + + + diff --git a/docs/imgs/anta_nrfu_mdreport_help.svg b/docs/imgs/anta_nrfu_mdreport_help.svg new file mode 100644 index 000000000..b0c3964f2 --- /dev/null +++ b/docs/imgs/anta_nrfu_mdreport_help.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu md-report --help + + + + + + + + + + Usage: anta nrfu md-report [OPTIONS] + +  ANTA command to check network state with Markdown report. + +Options: +  --md-output FILE  Path to save the report as a Markdown file  [env var: +                    ANTA_NRFU_MD_REPORT_MD_OUTPUT; required] +  --help            Show this message and exit. + + + + + diff --git a/docs/imgs/anta_nrfu_table_help.svg b/docs/imgs/anta_nrfu_table_help.svg new file mode 100644 index 000000000..55448db64 --- /dev/null +++ b/docs/imgs/anta_nrfu_table_help.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu table --help + + + + + + + + + + Usage: anta nrfu table [OPTIONS] + +  ANTA command to check network state with table results. + +Options: +  --group-by [device|test]  Group result by test or device. +  --help                    Show this message and exit. + + + + + diff --git a/docs/imgs/anta_nrfu_text_help.svg b/docs/imgs/anta_nrfu_text_help.svg new file mode 100644 index 000000000..c5929b9c1 --- /dev/null +++ b/docs/imgs/anta_nrfu_text_help.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu text --help + + + + + + + + + + Usage: anta nrfu text [OPTIONS] + +  ANTA command to check network state with text results. + +Options: +  --help  Show this message and exit. + + + + + diff --git a/docs/imgs/anta_nrfu_tplreport_help.svg b/docs/imgs/anta_nrfu_tplreport_help.svg new file mode 100644 index 000000000..77f30a06d --- /dev/null +++ b/docs/imgs/anta_nrfu_tplreport_help.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + anta nrfu tpl-report --help + + + + + + + + + + Usage: anta nrfu tpl-report [OPTIONS] + +  ANTA command to check network state with templated report. + +Options: +  -tpl, --template FILE  Path to the template to use for the report  [env var: +                         ANTA_NRFU_TPL_REPORT_TEMPLATE; required] +  -o, --output FILE      Path to save report as a file  [env var: +                         ANTA_NRFU_TPL_REPORT_OUTPUT] +  --help                 Show this message and exit. + + + + + diff --git a/docs/scripts/__init__.py b/docs/scripts/__init__.py deleted file mode 100644 index e702a5103..000000000 --- a/docs/scripts/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) 2024-2025 Arista Networks, Inc. -# Use of this source code is governed by the Apache License 2.0 -# that can be found in the LICENSE file. -"""Scripts for ANTA documentation.""" diff --git a/docs/scripts/generate_doc_snippets.py b/docs/scripts/generate_doc_snippets.py new file mode 100755 index 000000000..ccaa02ee7 --- /dev/null +++ b/docs/scripts/generate_doc_snippets.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# Copyright (c) 2024-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Generates SVG for documentation purposes.""" + +import sys +from pathlib import Path + +# TODO: svg in another PR +from generate_snippet import main as generate_snippet + +sys.path.insert(0, str(Path(__file__).parents[2])) + +COMMANDS = [ + "anta --help", + "anta nrfu --help", + "anta nrfu csv --help", + "anta nrfu json --help", + "anta nrfu table --help", + "anta nrfu text --help", + "anta nrfu tpl-report --help", + "anta nrfu md-report --help", +] + +for command in COMMANDS: + # TODO: svg in another PR + generate_snippet(command.split(" "), output="txt") diff --git a/docs/scripts/generate_svg.py b/docs/scripts/generate_snippet.py old mode 100644 new mode 100755 similarity index 62% rename from docs/scripts/generate_svg.py rename to docs/scripts/generate_snippet.py index 0add9f1b2..da3064553 --- a/docs/scripts/generate_svg.py +++ b/docs/scripts/generate_snippet.py @@ -1,11 +1,12 @@ +#!/usr/bin/env python # Copyright (c) 2023-2025 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""A script to generate svg files from anta command. +"""A script to generate svg or txt files from anta command. usage: -python generate_svg.py anta ... +python generate_snippet.py anta ... """ # This script is not a package # ruff: noqa: INP001 @@ -20,12 +21,15 @@ from contextlib import redirect_stdout, suppress from importlib import import_module from importlib.metadata import entry_points +from typing import Literal from unittest.mock import patch -from rich.console import Console from rich.logging import RichHandler +from rich.markup import escape from rich.progress import Progress +sys.path.insert(0, str(pathlib.Path(__file__).parents[2])) + from anta.cli.console import console from anta.cli.nrfu.utils import anta_progress_bar @@ -35,9 +39,6 @@ root.addHandler(r) -OUTPUT_DIR = pathlib.Path(__file__).parent.parent / "imgs" - - def custom_progress_bar() -> Progress: """Set the console of progress_bar to main anta console. @@ -50,12 +51,14 @@ def custom_progress_bar() -> Progress: return progress -if __name__ == "__main__": +def main(args: list[str], output: Literal["svg", "txt"] = "svg") -> None: + """Execute the script.""" # Sane rich size os.environ["COLUMNS"] = "120" + output_dir = pathlib.Path(__file__).parent.parent / "snippets" if output == "txt" else pathlib.Path(__file__).parent.parent / "imgs" + # stolen from https://github.com/ewels/rich-click/blob/main/src/rich_click/cli.py - args = sys.argv[1:] script_name = args[0] console_scripts = entry_points(group="console_scripts") scripts = {script.name: script for script in console_scripts} @@ -80,27 +83,32 @@ def custom_progress_bar() -> Progress: module = import_module(module_path) function = getattr(module, function_name) - # Console to captur everything - new_console = Console(record=True) - pipe = io.StringIO() console.record = True console.file = pipe - with redirect_stdout(io.StringIO()) as f: - # tweaks to record and redirect to a dummy file - - console.print(f"ant@anthill$ {' '.join(sys.argv)}") - - # Redirect stdout of the program towards another StringIO to capture help - # that is not part or anta rich console - # redirect potential progress bar output to console by patching - with patch("anta.cli.nrfu.utils.anta_progress_bar", custom_progress_bar), suppress(SystemExit): - function() + # Redirect stdout of the program towards another StringIO to capture help + # that is not part or anta rich console + # redirect potential progress bar output to console by patching + with redirect_stdout(io.StringIO()) as f, patch("anta.cli.nrfu.utils.anta_progress_bar", custom_progress_bar), suppress(SystemExit): + if output == "txt": + console.print(f"$ {' '.join(sys.argv)}") + function() if "--help" in args: - console.print(f.getvalue()) + console.print(escape(f.getvalue())) + + filename = f"{'_'.join(x.replace('/', '_').replace('-', '').replace('.', '') for x in args)}.{output}" + filename = output_dir / filename + if output == "txt": + content = console.export_text()[:-1] + with filename.open("w") as fd: + fd.write(content) + # TODO: Not using this to avoid newline console.save_text(str(filename)) + elif output == "svg": + console.save_svg(str(filename), title=" ".join(args)) - filename = f"{'_'.join(x.replace('/', '_').replace('-', '_').replace('.', '_') for x in args)}.svg" - filename = f"{OUTPUT_DIR}/{filename}" print(f"File saved at {filename}") - console.save_svg(filename, title=" ".join(args)) + + +if __name__ == "__main__": + main(sys.argv[1:], "txt") diff --git a/docs/snippets/anta_debug_help.txt b/docs/snippets/anta_debug_help.txt new file mode 100644 index 000000000..0b74be25b --- /dev/null +++ b/docs/snippets/anta_debug_help.txt @@ -0,0 +1,11 @@ +$ anta debug --help +Usage: anta debug [OPTIONS] COMMAND [ARGS]... + + Commands to execute EOS commands on remote devices. + +Options: + --help Show this message and exit. + +Commands: + run-cmd Run arbitrary command to an ANTA device. + run-template Run arbitrary templated command to an ANTA device. diff --git a/docs/snippets/anta_help.txt b/docs/snippets/anta_help.txt index 7bc37adeb..dd552fd04 100644 --- a/docs/snippets/anta_help.txt +++ b/docs/snippets/anta_help.txt @@ -1,8 +1,10 @@ +$ anta --help Usage: anta [OPTIONS] COMMAND [ARGS]... Arista Network Test Automation (ANTA) CLI. Options: + --help Show this message and exit. --version Show the version and exit. --log-file FILE Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to @@ -10,7 +12,6 @@ Options: -l, --log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG] ANTA logging level [env var: ANTA_LOG_LEVEL; default: INFO] - --help Show this message and exit. Commands: check Commands to validate configuration files. diff --git a/docs/snippets/anta_nrfu_csv_help.txt b/docs/snippets/anta_nrfu_csv_help.txt new file mode 100644 index 000000000..483b1c7d4 --- /dev/null +++ b/docs/snippets/anta_nrfu_csv_help.txt @@ -0,0 +1,9 @@ +$ anta nrfu csv --help +Usage: anta nrfu csv [OPTIONS] + + ANTA command to check network state with CSV report. + +Options: + --csv-output FILE Path to save report as a CSV file [env var: + ANTA_NRFU_CSV_CSV_OUTPUT; required] + --help Show this message and exit. diff --git a/docs/snippets/anta_nrfu_help.txt b/docs/snippets/anta_nrfu_help.txt index cb23fa7ed..5801f4e3a 100644 --- a/docs/snippets/anta_nrfu_help.txt +++ b/docs/snippets/anta_nrfu_help.txt @@ -1,3 +1,4 @@ +$ anta nrfu --help Usage: anta nrfu [OPTIONS] COMMAND [ARGS]... Run ANTA tests on selected inventory devices. diff --git a/docs/snippets/anta_nrfu_json_help.txt b/docs/snippets/anta_nrfu_json_help.txt new file mode 100644 index 000000000..6aebec9c4 --- /dev/null +++ b/docs/snippets/anta_nrfu_json_help.txt @@ -0,0 +1,11 @@ +$ anta nrfu json --help +Usage: anta nrfu json [OPTIONS] + + ANTA command to check network state with JSON results. + + If no `--output` is specified, the output is printed to stdout. + +Options: + -o, --output FILE Path to save report as a JSON file [env var: + ANTA_NRFU_JSON_OUTPUT] + --help Show this message and exit. diff --git a/docs/snippets/anta_nrfu_mdreport_help.txt b/docs/snippets/anta_nrfu_mdreport_help.txt new file mode 100644 index 000000000..0d4581190 --- /dev/null +++ b/docs/snippets/anta_nrfu_mdreport_help.txt @@ -0,0 +1,9 @@ +$ anta nrfu md-report --help +Usage: anta nrfu md-report [OPTIONS] + + ANTA command to check network state with Markdown report. + +Options: + --md-output FILE Path to save the report as a Markdown file [env var: + ANTA_NRFU_MD_REPORT_MD_OUTPUT; required] + --help Show this message and exit. diff --git a/docs/snippets/anta_nrfu_table_help.txt b/docs/snippets/anta_nrfu_table_help.txt new file mode 100644 index 000000000..9d368ab96 --- /dev/null +++ b/docs/snippets/anta_nrfu_table_help.txt @@ -0,0 +1,8 @@ +$ anta nrfu table --help +Usage: anta nrfu table [OPTIONS] + + ANTA command to check network state with table results. + +Options: + --group-by [device|test] Group result by test or device. + --help Show this message and exit. diff --git a/docs/snippets/anta_nrfu_text_help.txt b/docs/snippets/anta_nrfu_text_help.txt new file mode 100644 index 000000000..3bc587a90 --- /dev/null +++ b/docs/snippets/anta_nrfu_text_help.txt @@ -0,0 +1,7 @@ +$ anta nrfu text --help +Usage: anta nrfu text [OPTIONS] + + ANTA command to check network state with text results. + +Options: + --help Show this message and exit. diff --git a/docs/snippets/anta_nrfu_tplreport_help.txt b/docs/snippets/anta_nrfu_tplreport_help.txt new file mode 100644 index 000000000..b19bc8c19 --- /dev/null +++ b/docs/snippets/anta_nrfu_tplreport_help.txt @@ -0,0 +1,11 @@ +$ anta nrfu tpl-report --help +Usage: anta nrfu tpl-report [OPTIONS] + + ANTA command to check network state with templated report. + +Options: + -tpl, --template FILE Path to the template to use for the report [env var: + ANTA_NRFU_TPL_REPORT_TEMPLATE; required] + -o, --output FILE Path to save report as a file [env var: + ANTA_NRFU_TPL_REPORT_OUTPUT] + --help Show this message and exit. From 45f3b5be6f835fd399fd3c2bd5b11efa9bfb04d9 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Fri, 21 Feb 2025 14:36:17 -0500 Subject: [PATCH 08/13] fix(anta): Add Semaphore to AsyncEOSDevice (#1042) --- anta/device.py | 124 +++++++++++++++++++++++++++++-------------------- 1 file changed, 74 insertions(+), 50 deletions(-) diff --git a/anta/device.py b/anta/device.py index 3624fdb2e..f685357c5 100644 --- a/anta/device.py +++ b/anta/device.py @@ -32,6 +32,10 @@ # https://github.com/pyca/cryptography/issues/7236#issuecomment-1131908472 CLIENT_KEYS = asyncssh.public_key.load_default_keypairs() +# Limit concurrency to 100 requests (HTTPX default) to avoid high-concurrency performance issues +# See: https://github.com/encode/httpx/issues/3215 +MAX_CONCURRENT_REQUESTS = 100 + class AntaCache: """Class to be used as cache. @@ -296,6 +300,7 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ raise NotImplementedError(msg) +# pylint: disable=too-many-instance-attributes class AsyncEOSDevice(AntaDevice): """Implementation of AntaDevice for EOS using aio-eapi. @@ -388,6 +393,10 @@ def __init__( # noqa: PLR0913 host=host, port=ssh_port, username=username, password=password, client_keys=CLIENT_KEYS, **ssh_params ) + # In Python 3.9, Semaphore must be created within a running event loop + # TODO: Once we drop Python 3.9 support, initialize the semaphore here + self._command_semaphore: asyncio.Semaphore | None = None + def __rich_repr__(self) -> Iterator[tuple[str, Any]]: """Implement Rich Repr Protocol. @@ -431,6 +440,15 @@ def _keys(self) -> tuple[Any, ...]: """ return (self._session.host, self._session.port) + async def _get_semaphore(self) -> asyncio.Semaphore: + """Return the semaphore, initializing it if needed. + + TODO: Remove this method once we drop Python 3.9 support. + """ + if self._command_semaphore is None: + self._command_semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) + return self._command_semaphore + async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect device command output from EOS using aio-eapi. @@ -445,57 +463,63 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No collection_id An identifier used to build the eAPI request ID. """ - commands: list[dict[str, str | int]] = [] - if self.enable and self._enable_password is not None: - commands.append( - { - "cmd": "enable", - "input": str(self._enable_password), - }, - ) - elif self.enable: - # No password - commands.append({"cmd": "enable"}) - commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}] - try: - response: list[dict[str, Any] | str] = await self._session.cli( - commands=commands, - ofmt=command.ofmt, - version=command.version, - req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}", - ) # type: ignore[assignment] # multiple commands returns a list - # Do not keep response of 'enable' command - command.output = response[-1] - except asynceapi.EapiCommandError as e: - # This block catches exceptions related to EOS issuing an error. - self._log_eapi_command_error(command, e) - except TimeoutException as e: - # This block catches Timeout exceptions. - command.errors = [exc_to_str(e)] - timeouts = self._session.timeout.as_dict() - logger.error( - "%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s", - exc_to_str(e), - self.name, - timeouts["connect"], - timeouts["read"], - timeouts["write"], - timeouts["pool"], - ) - except (ConnectError, OSError) as e: - # This block catches OSError and socket issues related exceptions. - command.errors = [exc_to_str(e)] - if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance(os_error := e, OSError): # pylint: disable=no-member - if isinstance(os_error.__cause__, OSError): - os_error = os_error.__cause__ - logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error) - else: + semaphore = await self._get_semaphore() + + async with semaphore: + commands: list[dict[str, str | int]] = [] + if self.enable and self._enable_password is not None: + commands.append( + { + "cmd": "enable", + "input": str(self._enable_password), + }, + ) + elif self.enable: + # No password + commands.append({"cmd": "enable"}) + commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}] + try: + response: list[dict[str, Any] | str] = await self._session.cli( + commands=commands, + ofmt=command.ofmt, + version=command.version, + req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}", + ) # type: ignore[assignment] # multiple commands returns a list + # Do not keep response of 'enable' command + command.output = response[-1] + except asynceapi.EapiCommandError as e: + # This block catches exceptions related to EOS issuing an error. + self._log_eapi_command_error(command, e) + except TimeoutException as e: + # This block catches Timeout exceptions. + command.errors = [exc_to_str(e)] + timeouts = self._session.timeout.as_dict() + logger.error( + "%s occurred while sending a command to %s. Consider increasing the timeout.\nCurrent timeouts: Connect: %s | Read: %s | Write: %s | Pool: %s", + exc_to_str(e), + self.name, + timeouts["connect"], + timeouts["read"], + timeouts["write"], + timeouts["pool"], + ) + except (ConnectError, OSError) as e: + # This block catches OSError and socket issues related exceptions. + command.errors = [exc_to_str(e)] + # pylint: disable=no-member + if (isinstance(exc := e.__cause__, httpcore.ConnectError) and isinstance(os_error := exc.__context__, OSError)) or isinstance( + os_error := e, OSError + ): + if isinstance(os_error.__cause__, OSError): + os_error = os_error.__cause__ + logger.error("A local OS error occurred while connecting to %s: %s.", self.name, os_error) + else: + anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) + except HTTPError as e: + # This block catches most of the httpx Exceptions and logs a general message. + command.errors = [exc_to_str(e)] anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) - except HTTPError as e: - # This block catches most of the httpx Exceptions and logs a general message. - command.errors = [exc_to_str(e)] - anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger) - logger.debug("%s: %s", self.name, command) + logger.debug("%s: %s", self.name, command) def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None: """Appropriately log the eapi command error.""" From 710afc9203b8189745b1232bf19370e67a5dafee Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:35:53 +0100 Subject: [PATCH 09/13] ci: pre-commit autoupdate (#1058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.6 ā†’ v0.9.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.6...v0.9.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2b6e5d49..69a40ad95 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: - "" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.6 + rev: v0.9.7 hooks: - id: ruff name: Run Ruff linter From 91dea1adb5141f765dbf999063f116692a690696 Mon Sep 17 00:00:00 2001 From: geetanjalimanegslab <96573243+geetanjalimanegslab@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:54:26 +0530 Subject: [PATCH 10/13] =?UTF-8?q?refactor(anta.tests):=20Nicer=20result=20?= =?UTF-8?q?failure=20messages=20STP=20and=20System=20test=20module=C2=A0?= =?UTF-8?q?=20(#1043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Carl Baillargeon --- anta/input_models/system.py | 2 +- anta/tests/stp.py | 99 ++++++++++-------------- anta/tests/system.py | 36 ++++----- examples/tests.yaml | 2 +- tests/units/anta_tests/test_stp.py | 106 +++++++++++++++++++++++--- tests/units/anta_tests/test_system.py | 32 ++++---- 6 files changed, 168 insertions(+), 109 deletions(-) diff --git a/anta/input_models/system.py b/anta/input_models/system.py index fd4a27097..1771c1a63 100644 --- a/anta/input_models/system.py +++ b/anta/input_models/system.py @@ -28,4 +28,4 @@ class NTPServer(BaseModel): def __str__(self) -> str: """Representation of the NTPServer model.""" - return f"{self.server_address} (Preferred: {self.preferred}, Stratum: {self.stratum})" + return f"NTP Server: {self.server_address} Preferred: {self.preferred} Stratum: {self.stratum}" diff --git a/anta/tests/stp.py b/anta/tests/stp.py index 87e3cd104..40f72c18e 100644 --- a/anta/tests/stp.py +++ b/anta/tests/stp.py @@ -7,7 +7,7 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import Any, ClassVar, Literal +from typing import ClassVar, Literal from pydantic import Field @@ -54,8 +54,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPMode.""" - not_configured = [] - wrong_stp_mode = [] + self.result.is_success() for command in self.instance_commands: vlan_id = command.params.vlan if not ( @@ -64,15 +63,9 @@ def test(self) -> None: f"spanningTreeVlanInstances.{vlan_id}.spanningTreeVlanInstance.protocol", ) ): - not_configured.append(vlan_id) + self.result.is_failure(f"VLAN {vlan_id} STP mode: {self.inputs.mode} - Not configured") elif stp_mode != self.inputs.mode: - wrong_stp_mode.append(vlan_id) - if not_configured: - self.result.is_failure(f"STP mode '{self.inputs.mode}' not configured for the following VLAN(s): {not_configured}") - if wrong_stp_mode: - self.result.is_failure(f"Wrong STP mode configured for the following VLAN(s): {wrong_stp_mode}") - if not not_configured and not wrong_stp_mode: - self.result.is_success() + self.result.is_failure(f"VLAN {vlan_id} - Incorrect STP mode - Expected: {self.inputs.mode} Actual: {stp_mode}") class VerifySTPBlockedPorts(AntaTest): @@ -102,8 +95,8 @@ def test(self) -> None: self.result.is_success() else: for key, value in stp_instances.items(): - stp_instances[key] = value.pop("spanningTreeBlockedPorts") - self.result.is_failure(f"The following ports are blocked by STP: {stp_instances}") + stp_block_ports = value.get("spanningTreeBlockedPorts") + self.result.is_failure(f"STP Instance: {key} - Blocked ports - {', '.join(stp_block_ports)}") class VerifySTPCounters(AntaTest): @@ -128,14 +121,14 @@ class VerifySTPCounters(AntaTest): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPCounters.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - interfaces_with_errors = [ - interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0 - ] - if interfaces_with_errors: - self.result.is_failure(f"The following interfaces have STP BPDU packet errors: {interfaces_with_errors}") - else: - self.result.is_success() + + for interface, counters in command_output["interfaces"].items(): + if counters["bpduTaggedError"] != 0: + self.result.is_failure(f"Interface {interface} - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: {counters['bpduTaggedError']}") + if counters["bpduOtherError"] != 0: + self.result.is_failure(f"Interface {interface} - STP BPDU packet other errors count mismatch - Expected: 0 Actual: {counters['bpduOtherError']}") class VerifySTPForwardingPorts(AntaTest): @@ -174,25 +167,22 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPForwardingPorts.""" - not_configured = [] - not_forwarding = [] + self.result.is_success() + interfaces_state = [] for command in self.instance_commands: vlan_id = command.params.vlan if not (topologies := get_value(command.json_output, "topologies")): - not_configured.append(vlan_id) - else: - interfaces_not_forwarding = [] - for value in topologies.values(): - if vlan_id and int(vlan_id) in value["vlans"]: - interfaces_not_forwarding = [interface for interface, state in value["interfaces"].items() if state["state"] != "forwarding"] - if interfaces_not_forwarding: - not_forwarding.append({f"VLAN {vlan_id}": interfaces_not_forwarding}) - if not_configured: - self.result.is_failure(f"STP instance is not configured for the following VLAN(s): {not_configured}") - if not_forwarding: - self.result.is_failure(f"The following VLAN(s) have interface(s) that are not in a forwarding state: {not_forwarding}") - if not not_configured and not interfaces_not_forwarding: - self.result.is_success() + self.result.is_failure(f"VLAN {vlan_id} - STP instance is not configured") + continue + for value in topologies.values(): + if vlan_id and int(vlan_id) in value["vlans"]: + interfaces_state = [ + (interface, actual_state) for interface, state in value["interfaces"].items() if (actual_state := state["state"]) != "forwarding" + ] + + if interfaces_state: + for interface, state in interfaces_state: + self.result.is_failure(f"VLAN {vlan_id} Interface: {interface} - Invalid state - Expected: forwarding Actual: {state}") class VerifySTPRootPriority(AntaTest): @@ -229,6 +219,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifySTPRootPriority.""" + self.result.is_success() command_output = self.instance_commands[0].json_output if not (stp_instances := command_output["instances"]): self.result.is_failure("No STP instances configured") @@ -240,16 +231,15 @@ def test(self) -> None: elif first_name.startswith("VL"): prefix = "VL" else: - self.result.is_failure(f"Unsupported STP instance type: {first_name}") + self.result.is_failure(f"STP Instance: {first_name} - Unsupported STP instance type") return check_instances = [f"{prefix}{instance_id}" for instance_id in self.inputs.instances] if self.inputs.instances else command_output["instances"].keys() - wrong_priority_instances = [ - instance for instance in check_instances if get_value(command_output, f"instances.{instance}.rootBridge.priority") != self.inputs.priority - ] - if wrong_priority_instances: - self.result.is_failure(f"The following instance(s) have the wrong STP root priority configured: {wrong_priority_instances}") - else: - self.result.is_success() + for instance in check_instances: + if not (instance_details := get_value(command_output, f"instances.{instance}")): + self.result.is_failure(f"Instance: {instance} - Not configured") + continue + if (priority := get_value(instance_details, "rootBridge.priority")) != self.inputs.priority: + self.result.is_failure(f"STP Instance: {instance} - Incorrect root priority - Expected: {self.inputs.priority} Actual: {priority}") class VerifyStpTopologyChanges(AntaTest): @@ -282,8 +272,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyStpTopologyChanges.""" - failures: dict[str, Any] = {"topologies": {}} - + self.result.is_success() command_output = self.instance_commands[0].json_output stp_topologies = command_output.get("topologies", {}) @@ -297,18 +286,12 @@ def test(self) -> None: # Verifies the number of changes across all interfaces for topology, topology_details in stp_topologies.items(): - interfaces = { - interface: {"Number of changes": num_of_changes} - for interface, details in topology_details.get("interfaces", {}).items() - if (num_of_changes := details.get("numChanges")) > self.inputs.threshold - } - if interfaces: - failures["topologies"][topology] = interfaces - - if failures["topologies"]: - self.result.is_failure(f"The following STP topologies are not configured or number of changes not within the threshold:\n{failures}") - else: - self.result.is_success() + for interface, details in topology_details.get("interfaces", {}).items(): + if (num_of_changes := details.get("numChanges")) > self.inputs.threshold: + self.result.is_failure( + f"Topology: {topology} Interface: {interface} - Number of changes not within the threshold - Expected: " + f"{self.inputs.threshold} Actual: {num_of_changes}" + ) class VerifySTPDisabledVlans(AntaTest): diff --git a/anta/tests/system.py b/anta/tests/system.py index 11cf8398c..9ec719180 100644 --- a/anta/tests/system.py +++ b/anta/tests/system.py @@ -24,7 +24,7 @@ class VerifyUptime(AntaTest): - """Verifies if the device uptime is higher than the provided minimum uptime value. + """Verifies the device uptime. Expected Results ---------------- @@ -40,7 +40,6 @@ class VerifyUptime(AntaTest): ``` """ - description = "Verifies the device uptime." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)] @@ -53,11 +52,10 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyUptime.""" + self.result.is_success() command_output = self.instance_commands[0].json_output - if command_output["upTime"] > self.inputs.minimum: - self.result.is_success() - else: - self.result.is_failure(f"Device uptime is {command_output['upTime']} seconds") + if command_output["upTime"] < self.inputs.minimum: + self.result.is_failure(f"Device uptime is incorrect - Expected: {self.inputs.minimum} Actual: {command_output['upTime']} seconds") class VerifyReloadCause(AntaTest): @@ -96,11 +94,11 @@ def test(self) -> None: ]: self.result.is_success() else: - self.result.is_failure(f"Reload cause is: '{command_output_data}'") + self.result.is_failure(f"Reload cause is: {command_output_data}") class VerifyCoredump(AntaTest): - """Verifies if there are core dump files in the /var/core directory. + """Verifies there are no core dump files. Expected Results ---------------- @@ -119,7 +117,6 @@ class VerifyCoredump(AntaTest): ``` """ - description = "Verifies there are no core dump files." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)] @@ -133,7 +130,7 @@ def test(self) -> None: if not core_files: self.result.is_success() else: - self.result.is_failure(f"Core dump(s) have been found: {core_files}") + self.result.is_failure(f"Core dump(s) have been found: {', '.join(core_files)}") class VerifyAgentLogs(AntaTest): @@ -189,12 +186,11 @@ class VerifyCPUUtilization(AntaTest): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyCPUUtilization.""" + self.result.is_success() command_output = self.instance_commands[0].json_output command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"] - if command_output_data > CPU_IDLE_THRESHOLD: - self.result.is_success() - else: - self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%") + if command_output_data < CPU_IDLE_THRESHOLD: + self.result.is_failure(f"Device has reported a high CPU utilization - Expected: < 75% Actual: {100 - command_output_data}%") class VerifyMemoryUtilization(AntaTest): @@ -219,12 +215,11 @@ class VerifyMemoryUtilization(AntaTest): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMemoryUtilization.""" + self.result.is_success() command_output = self.instance_commands[0].json_output memory_usage = command_output["memFree"] / command_output["memTotal"] - if memory_usage > MEMORY_THRESHOLD: - self.result.is_success() - else: - self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage) * 100:.2f}%") + if memory_usage < MEMORY_THRESHOLD: + self.result.is_failure(f"Device has reported a high memory usage - Expected: < 75% Actual: {(1 - memory_usage) * 100:.2f}%") class VerifyFileSystemUtilization(AntaTest): @@ -253,7 +248,7 @@ def test(self) -> None: self.result.is_success() for line in command_output.split("\n")[1:]: if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > DISK_SPACE_THRESHOLD: - self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%") + self.result.is_failure(f"Mount point: {line} - Higher disk space utilization - Expected: {DISK_SPACE_THRESHOLD}% Actual: {percentage}%") class VerifyNTP(AntaTest): @@ -272,7 +267,6 @@ class VerifyNTP(AntaTest): ``` """ - description = "Verifies if NTP is synchronised." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")] @@ -284,7 +278,7 @@ def test(self) -> None: self.result.is_success() else: data = command_output.split("\n")[0] - self.result.is_failure(f"The device is not synchronized with the configured NTP server(s): '{data}'") + self.result.is_failure(f"NTP status mismatch - Expected: synchronised Actual: {data}") class VerifyNTPAssociations(AntaTest): diff --git a/examples/tests.yaml b/examples/tests.yaml index f5fd3ebd0..53108ea07 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -952,7 +952,7 @@ anta.tests.system: - VerifyMemoryUtilization: # Verifies whether the memory utilization is below 75%. - VerifyNTP: - # Verifies if NTP is synchronised. + # Verifies that the Network Time Protocol (NTP) is synchronized. - VerifyNTPAssociations: # Verifies the Network Time Protocol (NTP) associations. ntp_servers: diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py index 5de5df468..3dbd8c5d0 100644 --- a/tests/units/anta_tests/test_stp.py +++ b/tests/units/anta_tests/test_stp.py @@ -37,7 +37,7 @@ {"spanningTreeVlanInstances": {}}, ], "inputs": {"mode": "rstp", "vlans": [10, 20]}, - "expected": {"result": "failure", "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10, 20]"]}, + "expected": {"result": "failure", "messages": ["VLAN 10 STP mode: rstp - Not configured", "VLAN 20 STP mode: rstp - Not configured"]}, }, { "name": "failure-wrong-mode", @@ -47,7 +47,10 @@ {"spanningTreeVlanInstances": {"20": {"spanningTreeVlanInstance": {"protocol": "mstp"}}}}, ], "inputs": {"mode": "rstp", "vlans": [10, 20]}, - "expected": {"result": "failure", "messages": ["Wrong STP mode configured for the following VLAN(s): [10, 20]"]}, + "expected": { + "result": "failure", + "messages": ["VLAN 10 - Incorrect STP mode - Expected: rstp Actual: mstp", "VLAN 20 - Incorrect STP mode - Expected: rstp Actual: mstp"], + }, }, { "name": "failure-both", @@ -59,7 +62,7 @@ "inputs": {"mode": "rstp", "vlans": [10, 20]}, "expected": { "result": "failure", - "messages": ["STP mode 'rstp' not configured for the following VLAN(s): [10]", "Wrong STP mode configured for the following VLAN(s): [20]"], + "messages": ["VLAN 10 STP mode: rstp - Not configured", "VLAN 20 - Incorrect STP mode - Expected: rstp Actual: mstp"], }, }, { @@ -74,7 +77,7 @@ "test": VerifySTPBlockedPorts, "eos_data": [{"spanningTreeInstances": {"MST0": {"spanningTreeBlockedPorts": ["Ethernet10"]}, "MST10": {"spanningTreeBlockedPorts": ["Ethernet10"]}}}], "inputs": None, - "expected": {"result": "failure", "messages": ["The following ports are blocked by STP: {'MST0': ['Ethernet10'], 'MST10': ['Ethernet10']}"]}, + "expected": {"result": "failure", "messages": ["STP Instance: MST0 - Blocked ports - Ethernet10", "STP Instance: MST10 - Blocked ports - Ethernet10"]}, }, { "name": "success", @@ -84,18 +87,44 @@ "expected": {"result": "success"}, }, { - "name": "failure", + "name": "failure-bpdu-tagged-error-mismatch", "test": VerifySTPCounters, "eos_data": [ { "interfaces": { "Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "bpduRateLimitCount": 0}, + "Ethernet11": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "bpduRateLimitCount": 0}, + }, + }, + ], + "inputs": None, + "expected": { + "result": "failure", + "messages": [ + "Interface Ethernet10 - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: 3", + "Interface Ethernet11 - STP BPDU packet tagged errors count mismatch - Expected: 0 Actual: 3", + ], + }, + }, + { + "name": "failure-bpdu-other-error-mismatch", + "test": VerifySTPCounters, + "eos_data": [ + { + "interfaces": { + "Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 3, "bpduRateLimitCount": 0}, "Ethernet11": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 6, "bpduRateLimitCount": 0}, }, }, ], "inputs": None, - "expected": {"result": "failure", "messages": ["The following interfaces have STP BPDU packet errors: ['Ethernet10', 'Ethernet11']"]}, + "expected": { + "result": "failure", + "messages": [ + "Interface Ethernet10 - STP BPDU packet other errors count mismatch - Expected: 0 Actual: 3", + "Interface Ethernet11 - STP BPDU packet other errors count mismatch - Expected: 0 Actual: 6", + ], + }, }, { "name": "success", @@ -134,7 +163,7 @@ "test": VerifySTPForwardingPorts, "eos_data": [{"unmappedVlans": [], "topologies": {}}, {"unmappedVlans": [], "topologies": {}}], "inputs": {"vlans": [10, 20]}, - "expected": {"result": "failure", "messages": ["STP instance is not configured for the following VLAN(s): [10, 20]"]}, + "expected": {"result": "failure", "messages": ["VLAN 10 - STP instance is not configured", "VLAN 20 - STP instance is not configured"]}, }, { "name": "failure", @@ -152,7 +181,10 @@ "inputs": {"vlans": [10, 20]}, "expected": { "result": "failure", - "messages": ["The following VLAN(s) have interface(s) that are not in a forwarding state: [{'VLAN 10': ['Ethernet10']}, {'VLAN 20': ['Ethernet10']}]"], + "messages": [ + "VLAN 10 Interface: Ethernet10 - Invalid state - Expected: forwarding Actual: discarding", + "VLAN 20 Interface: Ethernet10 - Invalid state - Expected: forwarding Actual: discarding", + ], }, }, { @@ -261,6 +293,28 @@ "inputs": {"priority": 16384, "instances": [0]}, "expected": {"result": "success"}, }, + { + "name": "success-input-instance-none", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "MST0": { + "rootBridge": { + "priority": 16384, + "systemIdExtension": 0, + "macAddress": "02:1c:73:8b:93:ac", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + }, + }, + }, + }, + ], + "inputs": {"priority": 16384}, + "expected": {"result": "success"}, + }, { "name": "failure-no-instances", "test": VerifySTPRootPriority, @@ -281,7 +335,7 @@ }, ], "inputs": {"priority": 32768, "instances": [0]}, - "expected": {"result": "failure", "messages": ["Unsupported STP instance type: WRONG0"]}, + "expected": {"result": "failure", "messages": ["STP Instance: WRONG0 - Unsupported STP instance type"]}, }, { "name": "failure-wrong-instance-type", @@ -290,6 +344,28 @@ "inputs": {"priority": 32768, "instances": [10, 20]}, "expected": {"result": "failure", "messages": ["No STP instances configured"]}, }, + { + "name": "failure-instance-not-found", + "test": VerifySTPRootPriority, + "eos_data": [ + { + "instances": { + "VL10": { + "rootBridge": { + "priority": 32768, + "systemIdExtension": 10, + "macAddress": "00:1c:73:27:95:a2", + "helloTime": 2.0, + "maxAge": 20, + "forwardDelay": 15, + }, + } + } + } + ], + "inputs": {"priority": 32768, "instances": [11, 20]}, + "expected": {"result": "failure", "messages": ["Instance: VL11 - Not configured", "Instance: VL20 - Not configured"]}, + }, { "name": "failure-wrong-priority", "test": VerifySTPRootPriority, @@ -330,7 +406,13 @@ }, ], "inputs": {"priority": 32768, "instances": [10, 20, 30]}, - "expected": {"result": "failure", "messages": ["The following instance(s) have the wrong STP root priority configured: ['VL20', 'VL30']"]}, + "expected": { + "result": "failure", + "messages": [ + "STP Instance: VL20 - Incorrect root priority - Expected: 32768 Actual: 8196", + "STP Instance: VL30 - Incorrect root priority - Expected: 32768 Actual: 8196", + ], + }, }, { "name": "success-mstp", @@ -470,8 +552,8 @@ "expected": { "result": "failure", "messages": [ - "The following STP topologies are not configured or number of changes not within the threshold:\n" - "{'topologies': {'Cist': {'Cpu': {'Number of changes': 15}, 'Port-Channel5': {'Number of changes': 15}}}}" + "Topology: Cist Interface: Cpu - Number of changes not within the threshold - Expected: 10 Actual: 15", + "Topology: Cist Interface: Port-Channel5 - Number of changes not within the threshold - Expected: 10 Actual: 15", ], }, }, diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py index 858b793d1..5fe0fbad1 100644 --- a/tests/units/anta_tests/test_system.py +++ b/tests/units/anta_tests/test_system.py @@ -33,7 +33,7 @@ "test": VerifyUptime, "eos_data": [{"upTime": 665.15, "loadAvg": [0.13, 0.12, 0.09], "users": 1, "currentTime": 1683186659.139859}], "inputs": {"minimum": 666}, - "expected": {"result": "failure", "messages": ["Device uptime is 665.15 seconds"]}, + "expected": {"result": "failure", "messages": ["Device uptime is incorrect - Expected: 666 Actual: 665.15 seconds"]}, }, { "name": "success-no-reload", @@ -74,7 +74,7 @@ }, ], "inputs": None, - "expected": {"result": "failure", "messages": ["Reload cause is: 'Reload after crash.'"]}, + "expected": {"result": "failure", "messages": ["Reload cause is: Reload after crash."]}, }, { "name": "success-without-minidump", @@ -95,14 +95,14 @@ "test": VerifyCoredump, "eos_data": [{"mode": "compressedDeferred", "coreFiles": ["core.2344.1584483862.Mlag.gz", "core.23101.1584483867.Mlag.gz"]}], "inputs": None, - "expected": {"result": "failure", "messages": ["Core dump(s) have been found: ['core.2344.1584483862.Mlag.gz', 'core.23101.1584483867.Mlag.gz']"]}, + "expected": {"result": "failure", "messages": ["Core dump(s) have been found: core.2344.1584483862.Mlag.gz, core.23101.1584483867.Mlag.gz"]}, }, { "name": "failure-with-minidump", "test": VerifyCoredump, "eos_data": [{"mode": "compressedDeferred", "coreFiles": ["minidump", "core.2344.1584483862.Mlag.gz", "core.23101.1584483867.Mlag.gz"]}], "inputs": None, - "expected": {"result": "failure", "messages": ["Core dump(s) have been found: ['core.2344.1584483862.Mlag.gz', 'core.23101.1584483867.Mlag.gz']"]}, + "expected": {"result": "failure", "messages": ["Core dump(s) have been found: core.2344.1584483862.Mlag.gz, core.23101.1584483867.Mlag.gz"]}, }, { "name": "success", @@ -190,7 +190,7 @@ }, ], "inputs": None, - "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization: 75.2%"]}, + "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization - Expected: < 75% Actual: 75.2%"]}, }, { "name": "success", @@ -222,7 +222,7 @@ }, ], "inputs": None, - "expected": {"result": "failure", "messages": ["Device has reported a high memory usage: 95.56%"]}, + "expected": {"result": "failure", "messages": ["Device has reported a high memory usage - Expected: < 75% Actual: 95.56%"]}, }, { "name": "success", @@ -253,8 +253,8 @@ "expected": { "result": "failure", "messages": [ - "Mount point /dev/sda2 3.9G 988M 2.9G 84% /mnt/flash is higher than 75%: reported 84%", - "Mount point none 294M 78M 217M 84% /.overlay is higher than 75%: reported 84%", + "Mount point: /dev/sda2 3.9G 988M 2.9G 84% /mnt/flash - Higher disk space utilization - Expected: 75% Actual: 84%", + "Mount point: none 294M 78M 217M 84% /.overlay - Higher disk space utilization - Expected: 75% Actual: 84%", ], }, }, @@ -278,7 +278,7 @@ """, ], "inputs": None, - "expected": {"result": "failure", "messages": ["The device is not synchronized with the configured NTP server(s): 'unsynchronised'"]}, + "expected": {"result": "failure", "messages": ["NTP status mismatch - Expected: synchronised Actual: unsynchronised"]}, }, { "name": "success", @@ -413,9 +413,9 @@ "expected": { "result": "failure", "messages": [ - "1.1.1.1 (Preferred: True, Stratum: 1) - Bad association - Condition: candidate, Stratum: 2", - "2.2.2.2 (Preferred: False, Stratum: 2) - Bad association - Condition: sys.peer, Stratum: 2", - "3.3.3.3 (Preferred: False, Stratum: 2) - Bad association - Condition: sys.peer, Stratum: 3", + "NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Bad association - Condition: candidate, Stratum: 2", + "NTP Server: 2.2.2.2 Preferred: False Stratum: 2 - Bad association - Condition: sys.peer, Stratum: 2", + "NTP Server: 3.3.3.3 Preferred: False Stratum: 2 - Bad association - Condition: sys.peer, Stratum: 3", ], }, }, @@ -463,7 +463,7 @@ }, "expected": { "result": "failure", - "messages": ["3.3.3.3 (Preferred: False, Stratum: 1) - Not configured"], + "messages": ["NTP Server: 3.3.3.3 Preferred: False Stratum: 1 - Not configured"], }, }, { @@ -490,9 +490,9 @@ "expected": { "result": "failure", "messages": [ - "1.1.1.1 (Preferred: True, Stratum: 1) - Bad association - Condition: candidate, Stratum: 1", - "2.2.2.2 (Preferred: False, Stratum: 1) - Not configured", - "3.3.3.3 (Preferred: False, Stratum: 1) - Not configured", + "NTP Server: 1.1.1.1 Preferred: True Stratum: 1 - Bad association - Condition: candidate, Stratum: 1", + "NTP Server: 2.2.2.2 Preferred: False Stratum: 1 - Not configured", + "NTP Server: 3.3.3.3 Preferred: False Stratum: 1 - Not configured", ], }, }, From 5d68bd656099967b2eec88fd73cdab11019dd720 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:57:09 +0530 Subject: [PATCH 11/13] feat(anta.tests): Added negative testing in VerifyReachability test (#1053) --- anta/input_models/connectivity.py | 10 +++-- anta/tests/connectivity.py | 9 ++++- examples/tests.yaml | 2 + tests/units/anta_tests/test_connectivity.py | 41 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/anta/input_models/connectivity.py b/anta/input_models/connectivity.py index f967e775b..464d22a51 100644 --- a/anta/input_models/connectivity.py +++ b/anta/input_models/connectivity.py @@ -23,13 +23,15 @@ class Host(BaseModel): source: IPv4Address | IPv6Address | Interface """Source address IP or egress interface to use.""" vrf: str = "default" - """VRF context. Defaults to `default`.""" + """VRF context.""" repeat: int = 2 - """Number of ping repetition. Defaults to 2.""" + """Number of ping repetition.""" size: int = 100 - """Specify datagram size. Defaults to 100.""" + """Specify datagram size.""" df_bit: bool = False - """Enable do not fragment bit in IP header. Defaults to False.""" + """Enable do not fragment bit in IP header.""" + reachable: bool = True + """Indicates whether the destination should be reachable.""" def __str__(self) -> str: """Return a human-readable string representation of the Host for reporting. diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py index 2fd58c1bb..6568d8424 100644 --- a/anta/tests/connectivity.py +++ b/anta/tests/connectivity.py @@ -37,6 +37,7 @@ class VerifyReachability(AntaTest): vrf: MGMT df_bit: True size: 100 + reachable: true - source: Management0 destination: 8.8.8.8 vrf: MGMT @@ -47,6 +48,7 @@ class VerifyReachability(AntaTest): vrf: default df_bit: True size: 100 + reachable: false ``` """ @@ -89,9 +91,14 @@ def test(self) -> None: self.result.is_success() for command, host in zip(self.instance_commands, self.inputs.hosts): - if f"{host.repeat} received" not in command.json_output["messages"][0]: + # Verifies the network is reachable + if host.reachable and f"{host.repeat} received" not in command.json_output["messages"][0]: self.result.is_failure(f"{host} - Unreachable") + # Verifies the network is unreachable. + if not host.reachable and f"{host.repeat} received" in command.json_output["messages"][0]: + self.result.is_failure(f"{host} - Destination is expected to be unreachable but found reachable.") + class VerifyLLDPNeighbors(AntaTest): """Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors. diff --git a/examples/tests.yaml b/examples/tests.yaml index 53108ea07..6e5d88c66 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -130,6 +130,7 @@ anta.tests.connectivity: vrf: MGMT df_bit: True size: 100 + reachable: true - source: Management0 destination: 8.8.8.8 vrf: MGMT @@ -140,6 +141,7 @@ anta.tests.connectivity: vrf: default df_bit: True size: 100 + reachable: false anta.tests.cvx: - VerifyActiveCVXConnections: # Verifies the number of active CVX Connections. diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index 86ada5dea..1f5044b04 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -45,6 +45,23 @@ ], "expected": {"result": "success"}, }, + { + "name": "success-expected-unreachable", + "test": VerifyReachability, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data. + + --- 10.0.0.1 ping statistics --- + 2 packets transmitted, 0 received, 100% packet loss, time 10ms + """, + ], + }, + ], + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "10.0.0.5", "reachable": False}]}, + "expected": {"result": "success"}, + }, { "name": "success-ipv6", "test": VerifyReachability, @@ -268,6 +285,30 @@ ], "expected": {"result": "failure", "messages": ["Host: 10.0.0.1 Source: Management0 VRF: default - Unreachable"]}, }, + { + "name": "failure-expected-unreachable", + "test": VerifyReachability, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 10.0.0.5 : 72(100) bytes of data. + 80 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.247 ms + 80 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.072 ms + + --- 10.0.0.1 ping statistics --- + 2 packets transmitted, 2 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms + + """, + ], + }, + ], + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "10.0.0.5", "reachable": False}]}, + "expected": { + "result": "failure", + "messages": ["Host: 10.0.0.1 Source: 10.0.0.5 VRF: default - Destination is expected to be unreachable but found reachable."], + }, + }, { "name": "success", "test": VerifyLLDPNeighbors, From 202f6142dc919d7e1ce4a37ec4f421e18b183c15 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:57:32 +0530 Subject: [PATCH 12/13] fix(anta.tests): VerifyIPProxyARP and VerifyInterfaceIPv4 tests for 'Incomplete command' error (#1059) --- anta/input_models/interfaces.py | 31 +++- anta/tests/interfaces.py | 118 +++++-------- tests/units/anta_tests/test_interfaces.py | 175 ++++++++------------ tests/units/input_models/test_interfaces.py | 27 ++- 4 files changed, 166 insertions(+), 185 deletions(-) diff --git a/anta/input_models/interfaces.py b/anta/input_models/interfaces.py index 02ad76947..04cd4b223 100644 --- a/anta/input_models/interfaces.py +++ b/anta/input_models/interfaces.py @@ -5,7 +5,9 @@ from __future__ import annotations -from typing import Literal +from ipaddress import IPv4Interface +from typing import Any, Literal +from warnings import warn from pydantic import BaseModel, ConfigDict @@ -13,7 +15,10 @@ class InterfaceState(BaseModel): - """Model for an interface state.""" + """Model for an interface state. + + TODO: Need to review this class name in ANTA v2.0.0. + """ model_config = ConfigDict(extra="forbid") name: Interface @@ -33,6 +38,10 @@ class InterfaceState(BaseModel): Can be enabled in the `VerifyLACPInterfacesStatus` tests. """ + primary_ip: IPv4Interface | None = None + """Primary IPv4 address in CIDR notation. Required field in the `VerifyInterfaceIPv4` test.""" + secondary_ips: list[IPv4Interface] | None = None + """List of secondary IPv4 addresses in CIDR notation. Can be provided in the `VerifyInterfaceIPv4` test.""" def __str__(self) -> str: """Return a human-readable string representation of the InterfaceState for reporting. @@ -46,3 +55,21 @@ def __str__(self) -> str: if self.portchannel is not None: base_string += f" Port-Channel: {self.portchannel}" return base_string + + +class InterfaceDetail(InterfaceState): # pragma: no cover + """Alias for the InterfaceState model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the InterfaceState model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the InterfaceState class, emitting a depreciation warning.""" + warn( + message="InterfaceDetail model is deprecated and will be removed in ANTA v2.0.0. Use the InterfaceState model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py index 551c416b6..5701839de 100644 --- a/anta/tests/interfaces.py +++ b/anta/tests/interfaces.py @@ -8,16 +8,14 @@ from __future__ import annotations import re -from ipaddress import IPv4Interface from typing import Any, ClassVar, TypeVar from pydantic import BaseModel, Field, field_validator from pydantic_extra_types.mac_address import MacAddress -from anta import GITHUB_SUGGESTION from anta.custom_types import EthernetInterface, Interface, Percent, PositiveInteger from anta.decorators import skip_on_platforms -from anta.input_models.interfaces import InterfaceState +from anta.input_models.interfaces import InterfaceDetail, InterfaceState from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import custom_division, format_data, get_failed_logs, get_item, get_value @@ -517,7 +515,7 @@ def test(self) -> None: class VerifyIPProxyARP(AntaTest): - """Verifies if Proxy-ARP is enabled for the provided list of interface(s). + """Verifies if Proxy ARP is enabled. Expected Results ---------------- @@ -535,32 +533,28 @@ class VerifyIPProxyARP(AntaTest): ``` """ - description = "Verifies if Proxy ARP is enabled." categories: ClassVar[list[str]] = ["interfaces"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface", revision=2)] class Input(AntaTest.Input): """Input model for the VerifyIPProxyARP test.""" - interfaces: list[str] + interfaces: list[Interface] """List of interfaces to be tested.""" - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each interface in the input list.""" - return [template.render(intf=intf) for intf in self.inputs.interfaces] - @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyIPProxyARP.""" - disabled_intf = [] - for command in self.instance_commands: - intf = command.params.intf - if not command.json_output["interfaces"][intf]["proxyArp"]: - disabled_intf.append(intf) - if disabled_intf: - self.result.is_failure(f"The following interface(s) have Proxy-ARP disabled: {disabled_intf}") - else: - self.result.is_success() + self.result.is_success() + command_output = self.instance_commands[0].json_output + + for interface in self.inputs.interfaces: + if (interface_detail := get_value(command_output["interfaces"], f"{interface}", separator="..")) is None: + self.result.is_failure(f"Interface: {interface} - Not found") + continue + + if not interface_detail["proxyArp"]: + self.result.is_failure(f"Interface: {interface} - Proxy-ARP disabled") class VerifyL2MTU(AntaTest): @@ -628,7 +622,7 @@ def test(self) -> None: class VerifyInterfaceIPv4(AntaTest): - """Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses. + """Verifies the interface IPv4 addresses. Expected Results ---------------- @@ -649,83 +643,61 @@ class VerifyInterfaceIPv4(AntaTest): ``` """ - description = "Verifies the interface IPv4 addresses." categories: ClassVar[list[str]] = ["interfaces"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface", revision=2)] class Input(AntaTest.Input): """Input model for the VerifyInterfaceIPv4 test.""" - interfaces: list[InterfaceDetail] + interfaces: list[InterfaceState] """List of interfaces with their details.""" + InterfaceDetail: ClassVar[type[InterfaceDetail]] = InterfaceDetail - class InterfaceDetail(BaseModel): - """Model for an interface detail.""" - - name: Interface - """Name of the interface.""" - primary_ip: IPv4Interface - """Primary IPv4 address in CIDR notation.""" - secondary_ips: list[IPv4Interface] | None = None - """Optional list of secondary IPv4 addresses in CIDR notation.""" - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each interface in the input list.""" - return [template.render(interface=interface.name) for interface in self.inputs.interfaces] + @field_validator("interfaces") + @classmethod + def validate_interfaces(cls, interfaces: list[T]) -> list[T]: + """Validate that 'primary_ip' field is provided in each interface.""" + for interface in interfaces: + if interface.primary_ip is None: + msg = f"{interface} 'primary_ip' field missing in the input" + raise ValueError(msg) + return interfaces @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfaceIPv4.""" self.result.is_success() - for command in self.instance_commands: - intf = command.params.interface - for interface in self.inputs.interfaces: - if interface.name == intf: - input_interface_detail = interface - break - else: - self.result.is_failure(f"Could not find `{intf}` in the input interfaces. {GITHUB_SUGGESTION}") - continue - - input_primary_ip = str(input_interface_detail.primary_ip) - failed_messages = [] + command_output = self.instance_commands[0].json_output - # Check if the interface has an IP address configured - if not (interface_output := get_value(command.json_output, f"interfaces.{intf}.interfaceAddress")): - self.result.is_failure(f"For interface `{intf}`, IP address is not configured.") + for interface in self.inputs.interfaces: + if (interface_detail := get_value(command_output["interfaces"], f"{interface.name}", separator="..")) is None: + self.result.is_failure(f"{interface} - Not found") continue - primary_ip = get_value(interface_output, "primaryIp") + if (ip_address := get_value(interface_detail, "interfaceAddress.primaryIp")) is None: + self.result.is_failure(f"{interface} - IP address is not configured") + continue # Combine IP address and subnet for primary IP - actual_primary_ip = f"{primary_ip['address']}/{primary_ip['maskLen']}" + actual_primary_ip = f"{ip_address['address']}/{ip_address['maskLen']}" # Check if the primary IP address matches the input - if actual_primary_ip != input_primary_ip: - failed_messages.append(f"The expected primary IP address is `{input_primary_ip}`, but the actual primary IP address is `{actual_primary_ip}`.") + if actual_primary_ip != str(interface.primary_ip): + self.result.is_failure(f"{interface} - IP address mismatch - Expected: {interface.primary_ip} Actual: {actual_primary_ip}") - if (param_secondary_ips := input_interface_detail.secondary_ips) is not None: - input_secondary_ips = sorted([str(network) for network in param_secondary_ips]) - secondary_ips = get_value(interface_output, "secondaryIpsOrderedList") + if interface.secondary_ips: + if not (secondary_ips := get_value(interface_detail, "interfaceAddress.secondaryIpsOrderedList")): + self.result.is_failure(f"{interface} - Secondary IP address is not configured") + continue - # Combine IP address and subnet for secondary IPs actual_secondary_ips = sorted([f"{secondary_ip['address']}/{secondary_ip['maskLen']}" for secondary_ip in secondary_ips]) + input_secondary_ips = sorted([str(ip) for ip in interface.secondary_ips]) - # Check if the secondary IP address is configured - if not actual_secondary_ips: - failed_messages.append( - f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP address is not configured." + if actual_secondary_ips != input_secondary_ips: + self.result.is_failure( + f"{interface} - Secondary IP address mismatch - Expected: {', '.join(input_secondary_ips)} Actual: {', '.join(actual_secondary_ips)}" ) - # Check if the secondary IP addresses match the input - elif actual_secondary_ips != input_secondary_ips: - failed_messages.append( - f"The expected secondary IP addresses are `{input_secondary_ips}`, but the actual secondary IP addresses are `{actual_secondary_ips}`." - ) - - if failed_messages: - self.result.is_failure(f"For interface `{intf}`, " + " ".join(failed_messages)) - class VerifyIpVirtualRouterMac(AntaTest): """Verifies the IP virtual router MAC address. diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py index 9e5a87190..4d2bcb4ad 100644 --- a/tests/units/anta_tests/test_interfaces.py +++ b/tests/units/anta_tests/test_interfaces.py @@ -1859,45 +1859,13 @@ "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, }, }, }, @@ -1906,7 +1874,7 @@ "expected": {"result": "success"}, }, { - "name": "failure", + "name": "failure-interface-not-found", "test": VerifyIPProxyARP, "eos_data": [ { @@ -1915,51 +1883,37 @@ "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, }, }, }, + ], + "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]}, + "expected": {"result": "failure", "messages": ["Interface: Ethernet2 - Not found"]}, + }, + { + "name": "failure", + "test": VerifyIPProxyARP, + "eos_data": [ { "interfaces": { + "Ethernet1": { + "name": "Ethernet1", + "lineProtocolStatus": "up", + "interfaceStatus": "connected", + "proxyArp": True, + }, "Ethernet2": { "name": "Ethernet2", "lineProtocolStatus": "up", "interfaceStatus": "connected", - "mtu": 1500, - "interfaceAddressBrief": {"ipAddr": {"address": "10.1.0.2", "maskLen": 31}}, - "ipv4Routable240": False, - "ipv4Routable0": False, - "enabled": True, - "description": "P2P_LINK_TO_SW-CORE_Ethernet1", "proxyArp": False, - "localProxyArp": False, - "gratuitousArp": False, - "vrf": "default", - "urpf": "disable", - "addresslessForwarding": "isInvalid", - "directedBroadcastEnabled": False, - "maxMssIngress": 0, - "maxMssEgress": 0, }, }, }, ], "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]}, - "expected": {"result": "failure", "messages": ["The following interface(s) have Proxy-ARP disabled: ['Ethernet2']"]}, + "expected": {"result": "failure", "messages": ["Interface: Ethernet2 - Proxy-ARP disabled"]}, }, { "name": "success", @@ -1972,17 +1926,13 @@ "primaryIp": {"address": "172.30.11.1", "maskLen": 31}, "secondaryIpsOrderedList": [{"address": "10.10.10.1", "maskLen": 31}, {"address": "10.10.10.10", "maskLen": 31}], } - } - } - }, - { - "interfaces": { + }, "Ethernet12": { "interfaceAddress": { "primaryIp": {"address": "172.30.11.10", "maskLen": 31}, "secondaryIpsOrderedList": [{"address": "10.10.10.10", "maskLen": 31}, {"address": "10.10.10.20", "maskLen": 31}], } - } + }, } }, ], @@ -2005,17 +1955,13 @@ "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, "secondaryIpsOrderedList": [], } - } - } - }, - { - "interfaces": { + }, "Ethernet12": { "interfaceAddress": { "primaryIp": {"address": "172.30.11.10", "maskLen": 31}, "secondaryIpsOrderedList": [], } - } + }, } }, ], @@ -2027,10 +1973,36 @@ }, "expected": {"result": "success"}, }, + { + "name": "failure-interface-not-found", + "test": VerifyInterfaceIPv4, + "eos_data": [ + { + "interfaces": { + "Ethernet10": { + "interfaceAddress": { + "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, + "secondaryIpsOrderedList": [], + } + } + } + } + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]}, + {"name": "Ethernet12", "primary_ip": "172.30.11.20/31", "secondary_ips": ["10.10.11.0/31", "10.10.11.10/31"]}, + ] + }, + "expected": { + "result": "failure", + "messages": ["Interface: Ethernet2 - Not found", "Interface: Ethernet12 - Not found"], + }, + }, { "name": "failure-not-l3-interface", "test": VerifyInterfaceIPv4, - "eos_data": [{"interfaces": {"Ethernet2": {"interfaceAddress": {}}}}, {"interfaces": {"Ethernet12": {"interfaceAddress": {}}}}], + "eos_data": [{"interfaces": {"Ethernet2": {"interfaceAddress": {}}, "Ethernet12": {"interfaceAddress": {}}}}], "inputs": { "interfaces": [ {"name": "Ethernet2", "primary_ip": "172.30.11.0/31", "secondary_ips": ["10.10.10.0/31", "10.10.10.10/31"]}, @@ -2039,7 +2011,7 @@ }, "expected": { "result": "failure", - "messages": ["For interface `Ethernet2`, IP address is not configured.", "For interface `Ethernet12`, IP address is not configured."], + "messages": ["Interface: Ethernet2 - IP address is not configured", "Interface: Ethernet12 - IP address is not configured"], }, }, { @@ -2053,17 +2025,13 @@ "primaryIp": {"address": "0.0.0.0", "maskLen": 0}, "secondaryIpsOrderedList": [], } - } - } - }, - { - "interfaces": { + }, "Ethernet12": { "interfaceAddress": { "primaryIp": {"address": "0.0.0.0", "maskLen": 0}, "secondaryIpsOrderedList": [], } - } + }, } }, ], @@ -2076,10 +2044,10 @@ "expected": { "result": "failure", "messages": [ - "For interface `Ethernet2`, The expected primary IP address is `172.30.11.0/31`, but the actual primary IP address is `0.0.0.0/0`. " - "The expected secondary IP addresses are `['10.10.10.0/31', '10.10.10.10/31']`, but the actual secondary IP address is not configured.", - "For interface `Ethernet12`, The expected primary IP address is `172.30.11.10/31`, but the actual primary IP address is `0.0.0.0/0`. " - "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP address is not configured.", + "Interface: Ethernet2 - IP address mismatch - Expected: 172.30.11.0/31 Actual: 0.0.0.0/0", + "Interface: Ethernet2 - Secondary IP address is not configured", + "Interface: Ethernet12 - IP address mismatch - Expected: 172.30.11.10/31 Actual: 0.0.0.0/0", + "Interface: Ethernet12 - Secondary IP address is not configured", ], }, }, @@ -2094,17 +2062,13 @@ "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, "secondaryIpsOrderedList": [{"address": "10.10.10.0", "maskLen": 31}, {"address": "10.10.10.10", "maskLen": 31}], } - } - } - }, - { - "interfaces": { + }, "Ethernet3": { "interfaceAddress": { "primaryIp": {"address": "172.30.10.10", "maskLen": 31}, "secondaryIpsOrderedList": [{"address": "10.10.11.0", "maskLen": 31}, {"address": "10.11.11.10", "maskLen": 31}], } - } + }, } }, ], @@ -2117,12 +2081,10 @@ "expected": { "result": "failure", "messages": [ - "For interface `Ethernet2`, The expected primary IP address is `172.30.11.2/31`, but the actual primary IP address is `172.30.11.0/31`. " - "The expected secondary IP addresses are `['10.10.10.20/31', '10.10.10.30/31']`, but the actual secondary IP addresses are " - "`['10.10.10.0/31', '10.10.10.10/31']`.", - "For interface `Ethernet3`, The expected primary IP address is `172.30.10.2/31`, but the actual primary IP address is `172.30.10.10/31`. " - "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP addresses are " - "`['10.10.11.0/31', '10.11.11.10/31']`.", + "Interface: Ethernet2 - IP address mismatch - Expected: 172.30.11.2/31 Actual: 172.30.11.0/31", + "Interface: Ethernet2 - Secondary IP address mismatch - Expected: 10.10.10.20/31, 10.10.10.30/31 Actual: 10.10.10.0/31, 10.10.10.10/31", + "Interface: Ethernet3 - IP address mismatch - Expected: 172.30.10.2/31 Actual: 172.30.10.10/31", + "Interface: Ethernet3 - Secondary IP address mismatch - Expected: 10.10.11.0/31, 10.10.11.10/31 Actual: 10.10.11.0/31, 10.11.11.10/31", ], }, }, @@ -2137,17 +2099,13 @@ "primaryIp": {"address": "172.30.11.0", "maskLen": 31}, "secondaryIpsOrderedList": [], } - } - } - }, - { - "interfaces": { + }, "Ethernet3": { "interfaceAddress": { "primaryIp": {"address": "172.30.10.10", "maskLen": 31}, "secondaryIpsOrderedList": [{"address": "10.10.11.0", "maskLen": 31}, {"address": "10.11.11.10", "maskLen": 31}], } - } + }, } }, ], @@ -2160,11 +2118,10 @@ "expected": { "result": "failure", "messages": [ - "For interface `Ethernet2`, The expected primary IP address is `172.30.11.2/31`, but the actual primary IP address is `172.30.11.0/31`. " - "The expected secondary IP addresses are `['10.10.10.20/31', '10.10.10.30/31']`, but the actual secondary IP address is not configured.", - "For interface `Ethernet3`, The expected primary IP address is `172.30.10.2/31`, but the actual primary IP address is `172.30.10.10/31`. " - "The expected secondary IP addresses are `['10.10.11.0/31', '10.10.11.10/31']`, but the actual secondary IP addresses are " - "`['10.10.11.0/31', '10.11.11.10/31']`.", + "Interface: Ethernet2 - IP address mismatch - Expected: 172.30.11.2/31 Actual: 172.30.11.0/31", + "Interface: Ethernet2 - Secondary IP address is not configured", + "Interface: Ethernet3 - IP address mismatch - Expected: 172.30.10.2/31 Actual: 172.30.10.10/31", + "Interface: Ethernet3 - Secondary IP address mismatch - Expected: 10.10.11.0/31, 10.10.11.10/31 Actual: 10.10.11.0/31, 10.11.11.10/31", ], }, }, diff --git a/tests/units/input_models/test_interfaces.py b/tests/units/input_models/test_interfaces.py index aefa31941..881e3bdbe 100644 --- a/tests/units/input_models/test_interfaces.py +++ b/tests/units/input_models/test_interfaces.py @@ -12,7 +12,7 @@ from pydantic import ValidationError from anta.input_models.interfaces import InterfaceState -from anta.tests.interfaces import VerifyInterfacesStatus, VerifyLACPInterfacesStatus +from anta.tests.interfaces import VerifyInterfaceIPv4, VerifyInterfacesStatus, VerifyLACPInterfacesStatus if TYPE_CHECKING: from anta.custom_types import Interface, PortChannelInterface @@ -83,3 +83,28 @@ def test_invalid(self, interfaces: list[InterfaceState]) -> None: """Test VerifyLACPInterfacesStatus.Input invalid inputs.""" with pytest.raises(ValidationError): VerifyLACPInterfacesStatus.Input(interfaces=interfaces) + + +class TestVerifyInterfaceIPv4Input: + """Test anta.tests.interfaces.VerifyInterfaceIPv4.Input.""" + + @pytest.mark.parametrize( + ("interfaces"), + [ + pytest.param([{"name": "Ethernet1", "primary_ip": "172.30.11.1/31"}], id="valid"), + ], + ) + def test_valid(self, interfaces: list[InterfaceState]) -> None: + """Test VerifyInterfaceIPv4.Input valid inputs.""" + VerifyInterfaceIPv4.Input(interfaces=interfaces) + + @pytest.mark.parametrize( + ("interfaces"), + [ + pytest.param([{"name": "Ethernet1"}], id="invalid-no-primary-ip"), + ], + ) + def test_invalid(self, interfaces: list[InterfaceState]) -> None: + """Test VerifyInterfaceIPv4.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyInterfaceIPv4.Input(interfaces=interfaces) From 9ba02840ac3e49433be9554563e02c7d702a3f95 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Tue, 25 Feb 2025 21:05:13 +0530 Subject: [PATCH 13/13] refactor(anta.tests): Nicer result failure messages MLAG test module (#1039) --- anta/tests/mlag.py | 116 +++++++++++++++++----------- tests/units/anta_tests/test_mlag.py | 87 +++++++++++++-------- 2 files changed, 124 insertions(+), 79 deletions(-) diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py index 215630c91..708271ca7 100644 --- a/anta/tests/mlag.py +++ b/anta/tests/mlag.py @@ -22,10 +22,8 @@ class VerifyMlagStatus(AntaTest): Expected Results ---------------- - * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', - peer-link status and local interface status are 'up'. - * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', - peer-link status or local interface status are not 'up'. + * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', peer-link status and local interface status are 'up'. + * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', peer-link status or local interface status are not 'up'. * Skipped: The test will be skipped if MLAG is 'disabled'. Examples @@ -42,21 +40,25 @@ class VerifyMlagStatus(AntaTest): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagStatus.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return - keys_to_verify = ["state", "negStatus", "localIntfStatus", "peerLinkStatus"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if ( - verified_output["state"] == "active" - and verified_output["negStatus"] == "connected" - and verified_output["localIntfStatus"] == "up" - and verified_output["peerLinkStatus"] == "up" - ): - self.result.is_success() - else: - self.result.is_failure(f"MLAG status is not OK: {verified_output}") + + # Verifies the negotiation status + if (neg_status := command_output["negStatus"]) != "connected": + self.result.is_failure(f"MLAG negotiation status mismatch - Expected: connected Actual: {neg_status}") + + # Verifies the local interface interface status + if (intf_state := command_output["localIntfStatus"]) != "up": + self.result.is_failure(f"Operational state of the MLAG local interface is not correct - Expected: up Actual: {intf_state}") + + # Verifies the peerLinkStatus + if (peer_link_state := command_output["peerLinkStatus"]) != "up": + self.result.is_failure(f"Operational state of the MLAG peer link is not correct - Expected: up Actual: {peer_link_state}") class VerifyMlagInterfaces(AntaTest): @@ -82,14 +84,19 @@ class VerifyMlagInterfaces(AntaTest): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagInterfaces.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return - if command_output["mlagPorts"]["Inactive"] == 0 and command_output["mlagPorts"]["Active-partial"] == 0: - self.result.is_success() - else: - self.result.is_failure(f"MLAG status is not OK: {command_output['mlagPorts']}") + + # Verifies the Inactive and Active-partial ports + inactive_ports = command_output["mlagPorts"]["Inactive"] + partial_active_ports = command_output["mlagPorts"]["Active-partial"] + if inactive_ports != 0 or partial_active_ports != 0: + self.result.is_failure(f"MLAG status is not ok - Inactive Ports: {inactive_ports} Partial Active Ports: {partial_active_ports}") class VerifyMlagConfigSanity(AntaTest): @@ -116,16 +123,21 @@ class VerifyMlagConfigSanity(AntaTest): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagConfigSanity.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["mlagActive"] is False: self.result.is_skipped("MLAG is disabled") return - keys_to_verify = ["globalConfiguration", "interfaceConfiguration"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if not any(verified_output.values()): - self.result.is_success() - else: - self.result.is_failure(f"MLAG config-sanity returned inconsistencies: {verified_output}") + + # Verifies the globalConfiguration config-sanity + if get_value(command_output, "globalConfiguration"): + self.result.is_failure("MLAG config-sanity found in global configuration") + + # Verifies the interfaceConfiguration config-sanity + if get_value(command_output, "interfaceConfiguration"): + self.result.is_failure("MLAG config-sanity found in interface configuration") class VerifyMlagReloadDelay(AntaTest): @@ -161,17 +173,21 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagReloadDelay.""" + self.result.is_success() command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return - keys_to_verify = ["reloadDelay", "reloadDelayNonMlag"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if verified_output["reloadDelay"] == self.inputs.reload_delay and verified_output["reloadDelayNonMlag"] == self.inputs.reload_delay_non_mlag: - self.result.is_success() - else: - self.result.is_failure(f"The reload-delay parameters are not configured properly: {verified_output}") + # Verifies the reloadDelay + if (reload_delay := get_value(command_output, "reloadDelay")) != self.inputs.reload_delay: + self.result.is_failure(f"MLAG reload-delay mismatch - Expected: {self.inputs.reload_delay}s Actual: {reload_delay}s") + + # Verifies the reloadDelayNonMlag + if (non_mlag_reload_delay := get_value(command_output, "reloadDelayNonMlag")) != self.inputs.reload_delay_non_mlag: + self.result.is_failure(f"Delay for non-MLAG ports mismatch - Expected: {self.inputs.reload_delay_non_mlag}s Actual: {non_mlag_reload_delay}s") class VerifyMlagDualPrimary(AntaTest): @@ -214,25 +230,37 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyMlagDualPrimary.""" + self.result.is_success() errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none" command_output = self.instance_commands[0].json_output + + # Skipping the test if MLAG is disabled if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") return + + # Verifies the dualPrimaryDetectionState if command_output["dualPrimaryDetectionState"] == "disabled": self.result.is_failure("Dual-primary detection is disabled") return - keys_to_verify = ["detail.dualPrimaryDetectionDelay", "detail.dualPrimaryAction", "dualPrimaryMlagRecoveryDelay", "dualPrimaryNonMlagRecoveryDelay"] - verified_output = {key: get_value(command_output, key) for key in keys_to_verify} - if ( - verified_output["detail.dualPrimaryDetectionDelay"] == self.inputs.detection_delay - and verified_output["detail.dualPrimaryAction"] == errdisabled_action - and verified_output["dualPrimaryMlagRecoveryDelay"] == self.inputs.recovery_delay - and verified_output["dualPrimaryNonMlagRecoveryDelay"] == self.inputs.recovery_delay_non_mlag - ): - self.result.is_success() - else: - self.result.is_failure(f"The dual-primary parameters are not configured properly: {verified_output}") + + # Verifies the dualPrimaryAction + if (primary_action := get_value(command_output, "detail.dualPrimaryAction")) != errdisabled_action: + self.result.is_failure(f"Dual-primary action mismatch - Expected: {errdisabled_action} Actual: {primary_action}") + + # Verifies the dualPrimaryDetectionDelay + if (detection_delay := get_value(command_output, "detail.dualPrimaryDetectionDelay")) != self.inputs.detection_delay: + self.result.is_failure(f"Dual-primary detection delay mismatch - Expected: {self.inputs.detection_delay} Actual: {detection_delay}") + + # Verifies the dualPrimaryMlagRecoveryDelay + if (recovery_delay := get_value(command_output, "dualPrimaryMlagRecoveryDelay")) != self.inputs.recovery_delay: + self.result.is_failure(f"Dual-primary MLAG recovery delay mismatch - Expected: {self.inputs.recovery_delay} Actual: {recovery_delay}") + + # Verifies the dualPrimaryNonMlagRecoveryDelay + if (recovery_delay_non_mlag := get_value(command_output, "dualPrimaryNonMlagRecoveryDelay")) != self.inputs.recovery_delay_non_mlag: + self.result.is_failure( + f"Dual-primary non MLAG recovery delay mismatch - Expected: {self.inputs.recovery_delay_non_mlag} Actual: {recovery_delay_non_mlag}" + ) class VerifyMlagPrimaryPriority(AntaTest): @@ -282,6 +310,4 @@ def test(self) -> None: # Check primary priority if primary_priority != self.inputs.primary_priority: - self.result.is_failure( - f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.", - ) + self.result.is_failure(f"MLAG primary priority mismatch - Expected: {self.inputs.primary_priority} Actual: {primary_priority}") diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py index 387c88979..2a0cd25dc 100644 --- a/tests/units/anta_tests/test_mlag.py +++ b/tests/units/anta_tests/test_mlag.py @@ -30,13 +30,33 @@ "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, }, { - "name": "failure", + "name": "failure-negotiation-status", + "test": VerifyMlagStatus, + "eos_data": [{"state": "active", "negStatus": "connecting", "peerLinkStatus": "up", "localIntfStatus": "up"}], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["MLAG negotiation status mismatch - Expected: connected Actual: connecting"], + }, + }, + { + "name": "failure-local-interface", + "test": VerifyMlagStatus, + "eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "up", "localIntfStatus": "down"}], + "inputs": None, + "expected": { + "result": "failure", + "messages": ["Operational state of the MLAG local interface is not correct - Expected: up Actual: down"], + }, + }, + { + "name": "failure-peer-link", "test": VerifyMlagStatus, "eos_data": [{"state": "active", "negStatus": "connected", "peerLinkStatus": "down", "localIntfStatus": "up"}], "inputs": None, "expected": { "result": "failure", - "messages": ["MLAG status is not OK: {'state': 'active', 'negStatus': 'connected', 'localIntfStatus': 'up', 'peerLinkStatus': 'down'}"], + "messages": ["Operational state of the MLAG peer link is not correct - Expected: up Actual: down"], }, }, { @@ -74,7 +94,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 0, 'Active-partial': 1, 'Active-full': 1}"], + "messages": ["MLAG status is not ok - Inactive Ports: 0 Partial Active Ports: 1"], }, }, { @@ -89,7 +109,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["MLAG status is not OK: {'Disabled': 0, 'Configured': 0, 'Inactive': 1, 'Active-partial': 1, 'Active-full': 1}"], + "messages": ["MLAG status is not ok - Inactive Ports: 1 Partial Active Ports: 1"], }, }, { @@ -124,12 +144,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": [ - "MLAG config-sanity returned inconsistencies: " - "{'globalConfiguration': {'mlag': {'globalParameters': " - "{'dual-primary-detection-delay': {'localValue': '0', 'peerValue': '200'}}}}, " - "'interfaceConfiguration': {}}", - ], + "messages": ["MLAG config-sanity found in global configuration"], }, }, { @@ -146,12 +161,7 @@ "inputs": None, "expected": { "result": "failure", - "messages": [ - "MLAG config-sanity returned inconsistencies: " - "{'globalConfiguration': {}, " - "'interfaceConfiguration': {'trunk-native-vlan mlag30': " - "{'interface': {'Port-Channel30': {'localValue': '123', 'peerValue': '3700'}}}}}", - ], + "messages": ["MLAG config-sanity found in interface configuration"], }, }, { @@ -177,7 +187,10 @@ "test": VerifyMlagReloadDelay, "eos_data": [{"state": "active", "reloadDelay": 400, "reloadDelayNonMlag": 430}], "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330}, - "expected": {"result": "failure", "messages": ["The reload-delay parameters are not configured properly: {'reloadDelay': 400, 'reloadDelayNonMlag': 430}"]}, + "expected": { + "result": "failure", + "messages": ["MLAG reload-delay mismatch - Expected: 300s Actual: 400s", "Delay for non-MLAG ports mismatch - Expected: 330s Actual: 430s"], + }, }, { "name": "success", @@ -236,13 +249,8 @@ "expected": { "result": "failure", "messages": [ - ( - "The dual-primary parameters are not configured properly: " - "{'detail.dualPrimaryDetectionDelay': 300, " - "'detail.dualPrimaryAction': 'none', " - "'dualPrimaryMlagRecoveryDelay': 160, " - "'dualPrimaryNonMlagRecoveryDelay': 0}" - ), + "Dual-primary detection delay mismatch - Expected: 200 Actual: 300", + "Dual-primary MLAG recovery delay mismatch - Expected: 60 Actual: 160", ], }, }, @@ -262,15 +270,26 @@ "inputs": {"detection_delay": 200, "errdisabled": True, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, "expected": { "result": "failure", - "messages": [ - ( - "The dual-primary parameters are not configured properly: " - "{'detail.dualPrimaryDetectionDelay': 200, " - "'detail.dualPrimaryAction': 'none', " - "'dualPrimaryMlagRecoveryDelay': 60, " - "'dualPrimaryNonMlagRecoveryDelay': 0}" - ), - ], + "messages": ["Dual-primary action mismatch - Expected: errdisableAllInterfaces Actual: none"], + }, + }, + { + "name": "failure-wrong-non-mlag-delay", + "test": VerifyMlagDualPrimary, + "eos_data": [ + { + "state": "active", + "dualPrimaryDetectionState": "configured", + "dualPrimaryPortsErrdisabled": False, + "dualPrimaryMlagRecoveryDelay": 60, + "dualPrimaryNonMlagRecoveryDelay": 120, + "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "errdisableAllInterfaces"}, + }, + ], + "inputs": {"detection_delay": 200, "errdisabled": True, "recovery_delay": 60, "recovery_delay_non_mlag": 60}, + "expected": { + "result": "failure", + "messages": ["Dual-primary non MLAG recovery delay mismatch - Expected: 60 Actual: 120"], }, }, { @@ -325,7 +344,7 @@ "inputs": {"primary_priority": 1}, "expected": { "result": "failure", - "messages": ["The device is not set as MLAG primary.", "The primary priority does not match expected. Expected `1`, but found `32767` instead."], + "messages": ["The device is not set as MLAG primary.", "MLAG primary priority mismatch - Expected: 1 Actual: 32767"], }, }, ]