diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 000000000..a6d3a93ce --- /dev/null +++ b/.codespellignore @@ -0,0 +1 @@ +toi \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 446f18f14..a3e6256b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -84,6 +84,7 @@ repos: entry: codespell language: python types: [text] + args: ["--ignore-words", ".codespellignore"] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.14.1 diff --git a/anta/custom_types.py b/anta/custom_types.py index 8ca3c1c2f..ccd0b5f6e 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -23,16 +23,6 @@ REGEXP_TYPE_HOSTNAME = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" """Match hostname like `my-hostname`, `my-hostname-1`, `my-hostname-1-2`.""" -# Regexp BGP AFI/SAFI -REGEXP_BGP_L2VPN_AFI = r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b" -"""Match L2VPN EVPN AFI.""" -REGEXP_BGP_IPV4_MPLS_LABELS = r"\b(ipv4[\s\-]?mpls[\s\-]?label(s)?)\b" -"""Match IPv4 MPLS Labels.""" -REGEX_BGP_IPV4_MPLS_VPN = r"\b(ipv4[\s\-]?mpls[\s\-]?vpn)\b" -"""Match IPv4 MPLS VPN.""" -REGEX_BGP_IPV4_UNICAST = r"\b(ipv4[\s\-]?uni[\s\-]?cast)\b" -"""Match IPv4 Unicast.""" - def aaa_group_prefix(v: str) -> str: """Prefix the AAA method with 'group' if it is known.""" @@ -78,26 +68,57 @@ def interface_case_sensitivity(v: str) -> str: def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: """Abbreviations for different BGP multiprotocol capabilities. + Handles different separators (hyphen, underscore, space) and case sensitivity. + Examples -------- - - IPv4 Unicast - - L2vpnEVPN - - ipv4 MPLS Labels - - ipv4Mplsvpn - + ```python + >>> bgp_multiprotocol_capabilities_abbreviations("IPv4 Unicast") + 'ipv4Unicast' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv4-Flow_Spec Vpn") + 'ipv4FlowSpecVpn' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv6_labeled-unicast") + 'ipv6MplsLabels' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv4_mpls_vpn") + 'ipv4MplsVpn' + >>> bgp_multiprotocol_capabilities_abbreviations("ipv4 mpls labels") + 'ipv4MplsLabels' + >>> bgp_multiprotocol_capabilities_abbreviations("rt-membership") + 'rtMembership' + >>> bgp_multiprotocol_capabilities_abbreviations("dynamic-path-selection") + 'dps' + ``` """ patterns = { - REGEXP_BGP_L2VPN_AFI: "l2VpnEvpn", - REGEXP_BGP_IPV4_MPLS_LABELS: "ipv4MplsLabels", - REGEX_BGP_IPV4_MPLS_VPN: "ipv4MplsVpn", - REGEX_BGP_IPV4_UNICAST: "ipv4Unicast", + f"{r'dynamic[-_ ]?path[-_ ]?selection$'}": "dps", + f"{r'dps$'}": "dps", + f"{r'ipv4[-_ ]?unicast$'}": "ipv4Unicast", + f"{r'ipv6[-_ ]?unicast$'}": "ipv6Unicast", + f"{r'ipv4[-_ ]?multicast$'}": "ipv4Multicast", + f"{r'ipv6[-_ ]?multicast$'}": "ipv6Multicast", + f"{r'ipv4[-_ ]?labeled[-_ ]?Unicast$'}": "ipv4MplsLabels", + f"{r'ipv4[-_ ]?mpls[-_ ]?labels$'}": "ipv4MplsLabels", + f"{r'ipv6[-_ ]?labeled[-_ ]?Unicast$'}": "ipv6MplsLabels", + f"{r'ipv6[-_ ]?mpls[-_ ]?labels$'}": "ipv6MplsLabels", + f"{r'ipv4[-_ ]?sr[-_ ]?te$'}": "ipv4SrTe", # codespell:ignore + f"{r'ipv6[-_ ]?sr[-_ ]?te$'}": "ipv6SrTe", # codespell:ignore + f"{r'ipv4[-_ ]?mpls[-_ ]?vpn$'}": "ipv4MplsVpn", + f"{r'ipv6[-_ ]?mpls[-_ ]?vpn$'}": "ipv6MplsVpn", + f"{r'ipv4[-_ ]?Flow[-_ ]?spec$'}": "ipv4FlowSpec", + f"{r'ipv6[-_ ]?Flow[-_ ]?spec$'}": "ipv6FlowSpec", + f"{r'ipv4[-_ ]?Flow[-_ ]?spec[-_ ]?vpn$'}": "ipv4FlowSpecVpn", + f"{r'ipv6[-_ ]?Flow[-_ ]?spec[-_ ]?vpn$'}": "ipv6FlowSpecVpn", + f"{r'l2[-_ ]?vpn[-_ ]?vpls$'}": "l2VpnVpls", + f"{r'l2[-_ ]?vpn[-_ ]?evpn$'}": "l2VpnEvpn", + f"{r'link[-_ ]?state$'}": "linkState", + f"{r'rt[-_ ]?membership$'}": "rtMembership", + f"{r'ipv4[-_ ]?rt[-_ ]?membership$'}": "rtMembership", + f"{r'ipv4[-_ ]?mvpn$'}": "ipv4Mvpn", } - for pattern, replacement in patterns.items(): - match = re.search(pattern, value, re.IGNORECASE) + match = re.match(pattern, value, re.IGNORECASE) if match: return replacement - return value @@ -145,7 +166,31 @@ def validate_regex(value: str) -> str: EncryptionAlgorithm = Literal["RSA", "ECDSA"] RsaKeySize = Literal[2048, 3072, 4096] EcdsaKeySize = Literal[256, 384, 512] -MultiProtocolCaps = Annotated[str, BeforeValidator(bgp_multiprotocol_capabilities_abbreviations)] +MultiProtocolCaps = Annotated[ + Literal[ + "dps", + "ipv4Unicast", + "ipv6Unicast", + "ipv4Multicast", + "ipv6Multicast", + "ipv4MplsLabels", + "ipv6MplsLabels", + "ipv4SrTe", + "ipv6SrTe", + "ipv4MplsVpn", + "ipv6MplsVpn", + "ipv4FlowSpec", + "ipv6FlowSpec", + "ipv4FlowSpecVpn", + "ipv6FlowSpecVpn", + "l2VpnVpls", + "l2VpnEvpn", + "linkState", + "rtMembership", + "ipv4Mvpn", + ], + BeforeValidator(bgp_multiprotocol_capabilities_abbreviations), +] BfdInterval = Annotated[int, Field(ge=50, le=60000)] BfdMultiplier = Annotated[int, Field(ge=3, le=50)] ErrDisableReasons = Literal[ @@ -223,10 +268,6 @@ def validate_regex(value: str) -> str: ] BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"] -SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"] -SnmpErrorCounter = Literal[ - "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs" -] IPv4RouteType = Literal[ "connected", "static", @@ -256,8 +297,25 @@ def validate_regex(value: str) -> str: "Route Cache Route", "CBF Leaked Route", ] +DynamicVlanSource = Literal["dmf", "dot1x", "dynvtep", "evpn", "mlag", "mlagsync", "mvpn", "swfwd", "vccbfd"] +LogSeverityLevel = Literal["alerts", "critical", "debugging", "emergencies", "errors", "informational", "notifications", "warnings"] + + +######################################## +# SNMP +######################################## +def snmp_v3_prefix(auth_type: Literal["auth", "priv", "noauth"]) -> str: + """Prefix the SNMP authentication type with 'v3'.""" + if auth_type == "noauth": + return "v3NoAuth" + return f"v3{auth_type.title()}" + + SnmpVersion = Literal["v1", "v2c", "v3"] SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"] SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"] -DynamicVlanSource = Literal["dmf", "dot1x", "dynvtep", "evpn", "mlag", "mlagsync", "mvpn", "swfwd", "vccbfd"] -LogSeverityLevel = Literal["alerts", "critical", "debugging", "emergencies", "errors", "informational", "notifications", "warnings"] +SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"] +SnmpErrorCounter = Literal[ + "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs" +] +SnmpVersionV3AuthType = Annotated[Literal["auth", "priv", "noauth"], AfterValidator(snmp_v3_prefix)] diff --git a/anta/input_models/connectivity.py b/anta/input_models/connectivity.py index 1a904ac1d..f967e775b 100644 --- a/anta/input_models/connectivity.py +++ b/anta/input_models/connectivity.py @@ -36,11 +36,10 @@ def __str__(self) -> str: Examples -------- - Host 10.1.1.1 (src: 10.2.2.2, vrf: mgmt, size: 100B, repeat: 2) + Host: 10.1.1.1 Source: 10.2.2.2 VRF: mgmt """ - df_status = ", df-bit: enabled" if self.df_bit else "" - return f"Host {self.destination} (src: {self.source}, vrf: {self.vrf}, size: {self.size}B, repeat: {self.repeat}{df_status})" + return f"Host: {self.destination} Source: {self.source} VRF: {self.vrf}" class LLDPNeighbor(BaseModel): @@ -59,10 +58,10 @@ def __str__(self) -> str: Examples -------- - Port Ethernet1 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet2) + Port: Ethernet1 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet2 """ - return f"Port {self.port} (Neighbor: {self.neighbor_device}, Neighbor Port: {self.neighbor_port})" + return f"Port: {self.port} Neighbor: {self.neighbor_device} Neighbor Port: {self.neighbor_port}" class Neighbor(LLDPNeighbor): # pragma: no cover diff --git a/anta/input_models/routing/bgp.py b/anta/input_models/routing/bgp.py index 8b1256e18..945b0305c 100644 --- a/anta/input_models/routing/bgp.py +++ b/anta/input_models/routing/bgp.py @@ -224,8 +224,10 @@ class BgpRoute(BaseModel): """The IPv4 network address.""" vrf: str = "default" """Optional VRF for the BGP peer. Defaults to `default`.""" - paths: list[BgpRoutePath] - """A list of paths for the BGP route.""" + paths: list[BgpRoutePath] | None = None + """A list of paths for the BGP route. Required field in the `VerifyBGPRoutePaths` test.""" + ecmp_count: int | None = None + """The expected number of ECMP paths for the BGP route. Required field in the `VerifyBGPRouteECMP` test.""" def __str__(self) -> str: """Return a human-readable string representation of the BgpRoute for reporting. diff --git a/anta/input_models/routing/isis.py b/anta/input_models/routing/isis.py new file mode 100644 index 000000000..efeefe604 --- /dev/null +++ b/anta/input_models/routing/isis.py @@ -0,0 +1,124 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for routing IS-IS tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Any, Literal +from warnings import warn + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Interface + + +class ISISInstance(BaseModel): + """Model for an IS-IS instance.""" + + model_config = ConfigDict(extra="forbid") + name: str + """The name of the IS-IS instance.""" + vrf: str = "default" + """VRF context of the IS-IS instance.""" + dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS" + """Configured SR data-plane for the IS-IS instance.""" + segments: list[Segment] | None = None + """List of IS-IS SR segments associated with the instance. Required field in the `VerifyISISSegmentRoutingAdjacencySegments` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ISISInstance for reporting.""" + return f"Instance: {self.name} VRF: {self.vrf}" + + +class Segment(BaseModel): + """Model for an IS-IS segment.""" + + model_config = ConfigDict(extra="forbid") + interface: Interface + """Local interface name.""" + level: Literal[1, 2] = 2 + """IS-IS level of the segment.""" + sid_origin: Literal["dynamic", "configured"] = "dynamic" + """Origin of the segment ID.""" + address: IPv4Address + """Adjacency IPv4 address of the segment.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the Segment for reporting.""" + return f"Local Intf: {self.interface} Adj IP Address: {self.address}" + + +class ISISInterface(BaseModel): + """Model for an IS-IS enabled interface.""" + + model_config = ConfigDict(extra="forbid") + name: Interface + """Interface name.""" + vrf: str = "default" + """VRF context of the interface.""" + level: Literal[1, 2] = 2 + """IS-IS level of the interface.""" + count: int | None = None + """Expected number of IS-IS neighbors on this interface. Required field in the `VerifyISISNeighborCount` test.""" + mode: Literal["point-to-point", "broadcast", "passive"] | None = None + """IS-IS network type of the interface. Required field in the `VerifyISISInterfaceMode` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ISISInterface for reporting.""" + return f"Interface: {self.name} VRF: {self.vrf} Level: {self.level}" + + +class InterfaceCount(ISISInterface): # pragma: no cover + """Alias for the ISISInterface model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ISISInterface model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the InterfaceCount class, emitting a deprecation warning.""" + warn( + message="InterfaceCount model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInterface model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class InterfaceState(ISISInterface): # pragma: no cover + """Alias for the ISISInterface model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ISISInterface model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the InterfaceState class, emitting a deprecation warning.""" + warn( + message="InterfaceState model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInterface model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + + +class IsisInstance(ISISInstance): # pragma: no cover + """Alias for the ISISInstance model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the ISISInstance model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the IsisInstance class, emitting a deprecation warning.""" + warn( + message="IsisInstance model is deprecated and will be removed in ANTA v2.0.0. Use the ISISInstance model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/snmp.py b/anta/input_models/snmp.py index 0e032b834..9475d9cbe 100644 --- a/anta/input_models/snmp.py +++ b/anta/input_models/snmp.py @@ -6,11 +6,19 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Literal +from typing import TYPE_CHECKING, Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator -from anta.custom_types import Hostname, Interface, Port, SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion +from anta.custom_types import Hostname, Interface, Port, SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion, SnmpVersionV3AuthType + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self class SnmpHost(BaseModel): @@ -83,3 +91,37 @@ def __str__(self) -> str: - Source Interface: Ethernet1 VRF: default """ return f"Source Interface: {self.interface} VRF: {self.vrf}" + + +class SnmpGroup(BaseModel): + """Model for an SNMP group.""" + + group_name: str + """SNMP group name.""" + version: SnmpVersion + """SNMP protocol version.""" + read_view: str | None = None + """Optional field, View to restrict read access.""" + write_view: str | None = None + """Optional field, View to restrict write access.""" + notify_view: str | None = None + """Optional field, View to restrict notifications.""" + authentication: SnmpVersionV3AuthType | None = None + """SNMPv3 authentication settings. Required when version is v3. Can be provided in the `VerifySnmpGroup` test.""" + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the inputs provided to the SnmpGroup class.""" + if self.version == "v3" and self.authentication is None: + msg = f"{self!s}: `authentication` field is missing in the input" + raise ValueError(msg) + return self + + def __str__(self) -> str: + """Return a human-readable string representation of the SnmpGroup for reporting. + + Examples + -------- + - Group: Test_Group Version: v2c + """ + return f"Group: {self.group_name}, Version: {self.version}" diff --git a/anta/tests/aaa.py b/anta/tests/aaa.py index 22f751891..1dd228ea5 100644 --- a/anta/tests/aaa.py +++ b/anta/tests/aaa.py @@ -108,7 +108,7 @@ def test(self) -> None: if not not_configured: self.result.is_success() else: - self.result.is_failure(f"TACACS servers {not_configured} are not configured in VRF {self.inputs.vrf}") + self.result.is_failure(f"TACACS servers {', '.join(not_configured)} are not configured in VRF {self.inputs.vrf}") class VerifyTacacsServerGroups(AntaTest): @@ -151,7 +151,7 @@ def test(self) -> None: if not not_configured: self.result.is_success() else: - self.result.is_failure(f"TACACS server group(s) {not_configured} are not configured") + self.result.is_failure(f"TACACS server group(s) {', '.join(not_configured)} are not configured") class VerifyAuthenMethods(AntaTest): @@ -204,14 +204,14 @@ def test(self) -> None: self.result.is_failure("AAA authentication methods are not configured for login console") return if v["login"]["methods"] != self.inputs.methods: - self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console") + self.result.is_failure(f"AAA authentication methods {', '.join(self.inputs.methods)} are not matching for login console") return not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods) if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA authentication methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") class VerifyAuthzMethods(AntaTest): @@ -263,7 +263,7 @@ def test(self) -> None: if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA authorization methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA authorization methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") class VerifyAcctDefaultMethods(AntaTest): @@ -319,12 +319,12 @@ def test(self) -> None: if methods["defaultMethods"] != self.inputs.methods: not_matching.append(acct_type) if not_configured: - self.result.is_failure(f"AAA default accounting is not configured for {not_configured}") + self.result.is_failure(f"AAA default accounting is not configured for {', '.join(not_configured)}") return if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA accounting default methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA accounting default methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") class VerifyAcctConsoleMethods(AntaTest): @@ -380,9 +380,9 @@ def test(self) -> None: if methods["consoleMethods"] != self.inputs.methods: not_matching.append(acct_type) if not_configured: - self.result.is_failure(f"AAA console accounting is not configured for {not_configured}") + self.result.is_failure(f"AAA console accounting is not configured for {', '.join(not_configured)}") return if not not_matching: self.result.is_success() else: - self.result.is_failure(f"AAA accounting console methods {self.inputs.methods} are not matching for {not_matching}") + self.result.is_failure(f"AAA accounting console methods {', '.join(self.inputs.methods)} are not matching for {', '.join(not_matching)}") diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index 2361a4221..d33d3005d 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -362,5 +362,5 @@ def test(self) -> None: # Check registered protocols difference = sorted(set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps"))) if difference: - failures = " ".join(f"`{item}`" for item in difference) + failures = ", ".join(f"`{item}`" for item in difference) self.result.is_failure(f"{bfd_peer} - {failures} routing protocol(s) not configured") diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index bffeda7e7..b2677340f 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -3,11 +3,11 @@ # that can be found in the LICENSE file. """Module related to BGP tests.""" -# Mypy does not understand AntaTest.Input typing +# pylint: disable=too-many-lines # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import ClassVar, TypeVar +from typing import Any, ClassVar, TypeVar from pydantic import field_validator @@ -446,8 +446,6 @@ class VerifyBGPExchangedRoutes(AntaTest): advertised_routes: - 192.0.255.1/32 - 192.0.254.5/32 - received_routes: - - 192.0.254.3/32 ``` """ @@ -469,7 +467,7 @@ class Input(AntaTest.Input): def validate_bgp_peers(cls, bgp_peers: list[BgpPeer]) -> list[BgpPeer]: """Validate that 'advertised_routes' or 'received_routes' field is provided in each BGP peer.""" for peer in bgp_peers: - if peer.advertised_routes is None or peer.received_routes is None: + if peer.advertised_routes is None and peer.received_routes is None: msg = f"{peer} 'advertised_routes' or 'received_routes' field missing in the input" raise ValueError(msg) return bgp_peers @@ -478,6 +476,20 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each BGP peer in the input list.""" return [template.render(peer=str(bgp_peer.peer_address), vrf=bgp_peer.vrf) for bgp_peer in self.inputs.bgp_peers] + def _validate_bgp_route_paths(self, peer: str, route_type: str, route: str, entries: dict[str, Any]) -> str | None: + """Validate the BGP route paths.""" + # Check if the route is found + if route in entries: + # Check if the route is active and valid + route_paths = entries[route]["bgpRoutePaths"][0]["routeType"] + is_active = route_paths["active"] + is_valid = route_paths["valid"] + if not is_active or not is_valid: + return f"{peer} {route_type} route: {route} - Valid: {is_valid}, Active: {is_active}" + return None + + return f"{peer} {route_type} route: {route} - Not found" + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPExchangedRoutes.""" @@ -499,19 +511,16 @@ def test(self) -> None: # Validate both advertised and received routes for route_type, routes in zip(["Advertised", "Received"], [peer.advertised_routes, peer.received_routes]): + # Skipping the validation for routes if user input is None + if not routes: + continue + entries = command_output[route_type] for route in routes: - # Check if the route is found - if str(route) not in entries: - self.result.is_failure(f"{peer} {route_type} route: {route} - Not found") - continue - - # Check if the route is active and valid - route_paths = entries[str(route)]["bgpRoutePaths"][0]["routeType"] - is_active = route_paths["active"] - is_valid = route_paths["valid"] - if not is_active or not is_valid: - self.result.is_failure(f"{peer} {route_type} route: {route} - Valid: {is_valid}, Active: {is_active}") + # Check if the route is found. If yes then checks the route is active and valid + failure_msg = self._validate_bgp_route_paths(str(peer), route_type, str(route), entries) + if failure_msg: + self.result.is_failure(failure_msg) class VerifyBGPPeerMPCaps(AntaTest): @@ -550,7 +559,8 @@ class VerifyBGPPeerMPCaps(AntaTest): vrf: default strict: False capabilities: - - ipv4Unicast + - ipv4 labeled-Unicast + - ipv4MplsVpn ``` """ @@ -1665,6 +1675,16 @@ class Input(AntaTest.Input): route_entries: list[BgpRoute] """List of BGP IPv4 route(s).""" + @field_validator("route_entries") + @classmethod + def validate_route_entries(cls, route_entries: list[BgpRoute]) -> list[BgpRoute]: + """Validate that 'paths' field is provided in each BGP route.""" + for route in route_entries: + if route.paths is None: + msg = f"{route} 'paths' field missing in the input" + raise ValueError(msg) + return route_entries + @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPRoutePaths.""" @@ -1686,3 +1706,94 @@ def test(self) -> None: if (actual_origin := get_value(route_path, "routeType.origin")) != origin: self.result.is_failure(f"{route} {path} - Origin mismatch - Actual: {actual_origin}") + + +class VerifyBGPRouteECMP(AntaTest): + """Verifies BGP IPv4 route ECMP paths. + + This test performs the following checks for each specified BGP route entry: + + 1. Route exists in BGP table. + 2. First path is a valid and active ECMP head. + 3. Correct number of valid ECMP contributors follow the head path. + 4. Route is installed in RIB with same amount of next-hops. + + Expected Results + ---------------- + * Success: The test will pass if all specified routes exist in both BGP and RIB tables with correct amount of ECMP paths. + * Failure: The test will fail if: + - A specified route is not found in BGP table. + - A valid and active ECMP head is not found. + - ECMP contributors count does not match the expected value. + - Route is not installed in RIB table. + - BGP and RIB nexthops count do not match. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPRouteECMP: + route_entries: + - prefix: 10.100.0.128/31 + vrf: default + ecmp_count: 2 + ``` + """ + + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaCommand(command="show ip bgp vrf all", revision=3), + AntaCommand(command="show ip route vrf all bgp", revision=4), + ] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPRouteECMP test.""" + + route_entries: list[BgpRoute] + """List of BGP IPv4 route(s).""" + + @field_validator("route_entries") + @classmethod + def validate_route_entries(cls, route_entries: list[BgpRoute]) -> list[BgpRoute]: + """Validate that 'ecmp_count' field is provided in each BGP route.""" + for route in route_entries: + if route.ecmp_count is None: + msg = f"{route} 'ecmp_count' field missing in the input" + raise ValueError(msg) + return route_entries + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPRouteECMP.""" + self.result.is_success() + + for route in self.inputs.route_entries: + # Verify if the prefix exists in BGP table. + if not (bgp_route_entry := get_value(self.instance_commands[0].json_output, f"vrfs..{route.vrf}..bgpRouteEntries..{route.prefix}", separator="..")): + self.result.is_failure(f"{route} - prefix not found in BGP table") + continue + + route_paths = iter(bgp_route_entry["bgpRoutePaths"]) + head = next(route_paths, None) + # Verify if the active ECMP head exists. + if head is None or not all(head["routeType"][key] for key in ["valid", "active", "ecmpHead"]): + self.result.is_failure(f"{route} - valid and active ECMP head not found") + continue + + bgp_nexthops = {head["nextHop"]} + bgp_nexthops.update([path["nextHop"] for path in route_paths if all(path["routeType"][key] for key in ["valid", "ecmp", "ecmpContributor"])]) + + # Verify ECMP count is correct. + if len(bgp_nexthops) != route.ecmp_count: + self.result.is_failure(f"{route} - ECMP count mismatch - Expected: {route.ecmp_count}, Actual: {len(bgp_nexthops)}") + continue + + # Verify if the prefix exists in routing table. + if not (route_entry := get_value(self.instance_commands[1].json_output, f"vrfs..{route.vrf}..routes..{route.prefix}", separator="..")): + self.result.is_failure(f"{route} - prefix not found in routing table") + continue + + # Verify BGP and RIB nexthops are same. + if len(bgp_nexthops) != len(route_entry["vias"]): + self.result.is_failure(f"{route} - Nexthops count mismatch - BGP: {len(bgp_nexthops)}, RIB: {len(route_entry['vias'])}") diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py index 562ff6d65..47803e3a2 100644 --- a/anta/tests/routing/isis.py +++ b/anta/tests/routing/isis.py @@ -10,144 +10,22 @@ from ipaddress import IPv4Address, IPv4Network from typing import Any, ClassVar, Literal -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from anta.custom_types import Interface +from anta.input_models.routing.isis import InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_value - - -def _count_isis_neighbor(isis_neighbor_json: dict[str, Any]) -> int: - """Count the number of isis neighbors. - - Parameters - ---------- - isis_neighbor_json - The JSON output of the `show isis neighbors` command. - - Returns - ------- - int - The number of isis neighbors. - - """ - count = 0 - for vrf_data in isis_neighbor_json["vrfs"].values(): - for instance_data in vrf_data["isisInstances"].values(): - count += len(instance_data.get("neighbors", {})) - return count - - -def _get_not_full_isis_neighbors(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: - """Return the isis neighbors whose adjacency state is not `up`. - - Parameters - ---------- - isis_neighbor_json - The JSON output of the `show isis neighbors` command. - - Returns - ------- - list[dict[str, Any]] - A list of isis neighbors whose adjacency state is not `UP`. - - """ - return [ - { - "vrf": vrf, - "instance": instance, - "neighbor": adjacency["hostname"], - "state": state, - } - for vrf, vrf_data in isis_neighbor_json["vrfs"].items() - for instance, instance_data in vrf_data.get("isisInstances").items() - for neighbor, neighbor_data in instance_data.get("neighbors").items() - for adjacency in neighbor_data.get("adjacencies") - if (state := adjacency["state"]) != "up" - ] - - -def _get_full_isis_neighbors(isis_neighbor_json: dict[str, Any], neighbor_state: Literal["up", "down"] = "up") -> list[dict[str, Any]]: - """Return the isis neighbors whose adjacency state is `up`. - - Parameters - ---------- - isis_neighbor_json - The JSON output of the `show isis neighbors` command. - neighbor_state - Value of the neihbor state we are looking for. Defaults to `up`. - - Returns - ------- - list[dict[str, Any]] - A list of isis neighbors whose adjacency state is not `UP`. - - """ - return [ - { - "vrf": vrf, - "instance": instance, - "neighbor": adjacency["hostname"], - "neighbor_address": adjacency["routerIdV4"], - "interface": adjacency["interfaceName"], - "state": state, - } - for vrf, vrf_data in isis_neighbor_json["vrfs"].items() - for instance, instance_data in vrf_data.get("isisInstances").items() - for neighbor, neighbor_data in instance_data.get("neighbors").items() - for adjacency in neighbor_data.get("adjacencies") - if (state := adjacency["state"]) == neighbor_state - ] - - -def _get_isis_neighbors_count(isis_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: - """Count number of IS-IS neighbor of the device.""" - return [ - {"vrf": vrf, "interface": interface, "mode": mode, "count": int(level_data["numAdjacencies"]), "level": int(level)} - for vrf, vrf_data in isis_neighbor_json["vrfs"].items() - for instance, instance_data in vrf_data.get("isisInstances").items() - for interface, interface_data in instance_data.get("interfaces").items() - for level, level_data in interface_data.get("intfLevels").items() - if (mode := level_data["passive"]) is not True - ] - - -def _get_interface_data(interface: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None: - """Extract data related to an IS-IS interface for testing.""" - if (vrf_data := get_value(command_output, f"vrfs.{vrf}")) is None: - return None - - for instance_data in vrf_data.get("isisInstances").values(): - if (intf_dict := get_value(dictionary=instance_data, key="interfaces")) is not None: - try: - return next(ifl_data for ifl, ifl_data in intf_dict.items() if ifl == interface) - except StopIteration: - return None - return None - - -def _get_adjacency_segment_data_by_neighbor(neighbor: str, instance: str, vrf: str, command_output: dict[str, Any]) -> dict[str, Any] | None: - """Extract data related to an IS-IS interface for testing.""" - search_path = f"vrfs.{vrf}.isisInstances.{instance}.adjacencySegments" - if get_value(dictionary=command_output, key=search_path, default=None) is None: - return None - - isis_instance = get_value(dictionary=command_output, key=search_path, default=None) - - return next( - (segment_data for segment_data in isis_instance if neighbor == segment_data["ipAddress"]), - None, - ) +from anta.tools import get_item, get_value class VerifyISISNeighborState(AntaTest): - """Verifies all IS-IS neighbors are in UP state. + """Verifies the health of IS-IS neighbors. Expected Results ---------------- - * Success: The test will pass if all IS-IS neighbors are in UP state. - * Failure: The test will fail if some IS-IS neighbors are not in UP state. - * Skipped: The test will be skipped if no IS-IS neighbor is found. + * Success: The test will pass if all IS-IS neighbors are in the `up` state. + * Failure: The test will fail if any IS-IS neighbor adjacency is down. + * Skipped: The test will be skipped if IS-IS is not configured or no IS-IS neighbor is found. Examples -------- @@ -155,33 +33,56 @@ class VerifyISISNeighborState(AntaTest): anta.tests.routing: isis: - VerifyISISNeighborState: + check_all_vrfs: true ``` """ categories: ClassVar[list[str]] = ["isis"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors vrf all", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyISISNeighborState test.""" + + check_all_vrfs: bool = False + """If enabled, verifies IS-IS instances of all VRFs.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyISISNeighborState.""" - command_output = self.instance_commands[0].json_output - if _count_isis_neighbor(command_output) == 0: - self.result.is_skipped("No IS-IS neighbor detected") - return self.result.is_success() - not_full_neighbors = _get_not_full_isis_neighbors(command_output) - if not_full_neighbors: - self.result.is_failure(f"Some neighbors are not in the correct state (UP): {not_full_neighbors}.") + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") + return + + vrfs_to_check = command_output + if not self.inputs.check_all_vrfs: + vrfs_to_check = {"default": command_output["default"]} + + no_neighbor = True + for vrf, vrf_data in vrfs_to_check.items(): + for isis_instance, instance_data in vrf_data["isisInstances"].items(): + neighbors = instance_data["neighbors"] + if not neighbors: + continue + no_neighbor = False + interfaces = [adj["interfaceName"] for neighbor in neighbors.values() for adj in neighbor["adjacencies"] if adj["state"] != "up"] + for interface in interfaces: + self.result.is_failure(f"Instance: {isis_instance} VRF: {vrf} Interface: {interface} - Adjacency down") + + if no_neighbor: + self.result.is_skipped("No IS-IS neighbor detected") class VerifyISISNeighborCount(AntaTest): - """Verifies number of IS-IS neighbors per level and per interface. + """Verifies the number of IS-IS neighbors per interface and level. Expected Results ---------------- - * Success: The test will pass if the number of neighbors is correct. - * Failure: The test will fail if the number of neighbors is incorrect. - * Skipped: The test will be skipped if no IS-IS neighbor is found. + * Success: The test will pass if all provided IS-IS interfaces have the expected number of neighbors. + * Failure: The test will fail if any of the provided IS-IS interfaces are not configured or have an incorrect number of neighbors. + * Skipped: The test will be skipped if IS-IS is not configured. Examples -------- @@ -198,59 +99,54 @@ class VerifyISISNeighborCount(AntaTest): count: 1 - name: Ethernet3 count: 2 - # level is set to 2 by default ``` """ categories: ClassVar[list[str]] = ["isis"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief vrf all", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyISISNeighborCount test.""" - interfaces: list[InterfaceCount] - """list of interfaces with their information.""" - - class InterfaceCount(BaseModel): - """Input model for the VerifyISISNeighborCount test.""" - - name: Interface - """Interface name to check.""" - level: int = 2 - """IS-IS level to check.""" - count: int - """Number of IS-IS neighbors.""" + interfaces: list[ISISInterface] + """List of IS-IS interfaces with their information.""" + InterfaceCount: ClassVar[type[InterfaceCount]] = InterfaceCount @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyISISNeighborCount.""" - command_output = self.instance_commands[0].json_output self.result.is_success() - isis_neighbor_count = _get_isis_neighbors_count(command_output) - if len(isis_neighbor_count) == 0: - self.result.is_skipped("No IS-IS neighbor detected") + + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") return + for interface in self.inputs.interfaces: - eos_data = [ifl_data for ifl_data in isis_neighbor_count if ifl_data["interface"] == interface.name and ifl_data["level"] == interface.level] - if not eos_data: - self.result.is_failure(f"No neighbor detected for interface {interface.name}") + interface_detail = {} + vrf_instances = get_value(command_output, f"{interface.vrf}..isisInstances", default={}, separator="..") + for instance_data in vrf_instances.values(): + if interface_data := get_value(instance_data, f"interfaces..{interface.name}..intfLevels..{interface.level}", separator=".."): + interface_detail = interface_data + # An interface can only be configured in one IS-IS instance at a time + break + + if not interface_detail: + self.result.is_failure(f"{interface} - Not configured") continue - if eos_data[0]["count"] != interface.count: - self.result.is_failure( - f"Interface {interface.name}: " - f"expected Level {interface.level}: count {interface.count}, " - f"got Level {eos_data[0]['level']}: count {eos_data[0]['count']}" - ) + + if interface_detail["passive"] is False and (act_count := interface_detail["numAdjacencies"]) != interface.count: + self.result.is_failure(f"{interface} - Neighbor count mismatch - Expected: {interface.count} Actual: {act_count}") class VerifyISISInterfaceMode(AntaTest): - """Verifies ISIS Interfaces are running in correct mode. + """Verifies IS-IS interfaces are running in the correct mode. Expected Results ---------------- - * Success: The test will pass if all listed interfaces are running in correct mode. - * Failure: The test will fail if any of the listed interfaces is not running in correct mode. - * Skipped: The test will be skipped if no ISIS neighbor is found. + * Success: The test will pass if all provided IS-IS interfaces are running in the correct mode. + * Failure: The test will fail if any of the provided IS-IS interfaces are not configured or running in the incorrect mode. + * Skipped: The test will be skipped if IS-IS is not configured. Examples -------- @@ -261,80 +157,71 @@ class VerifyISISInterfaceMode(AntaTest): interfaces: - name: Loopback0 mode: passive - # vrf is set to default by default - name: Ethernet2 mode: passive level: 2 - # vrf is set to default by default - name: Ethernet1 mode: point-to-point - vrf: default - # level is set to 2 by default + vrf: PROD ``` """ - description = "Verifies interface mode for IS-IS" categories: ClassVar[list[str]] = ["isis"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief vrf all", revision=1)] class Input(AntaTest.Input): - """Input model for the VerifyISISNeighborCount test.""" + """Input model for the VerifyISISInterfaceMode test.""" - interfaces: list[InterfaceState] - """list of interfaces with their information.""" - - class InterfaceState(BaseModel): - """Input model for the VerifyISISNeighborCount test.""" - - name: Interface - """Interface name to check.""" - level: Literal[1, 2] = 2 - """ISIS level configured for interface. Default is 2.""" - mode: Literal["point-to-point", "broadcast", "passive"] - """Number of IS-IS neighbors.""" - vrf: str = "default" - """VRF where the interface should be configured""" + interfaces: list[ISISInterface] + """List of IS-IS interfaces with their information.""" + InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyISISInterfaceMode.""" - command_output = self.instance_commands[0].json_output self.result.is_success() - if len(command_output["vrfs"]) == 0: - self.result.is_skipped("IS-IS is not configured on device") + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") return - # Check for p2p interfaces for interface in self.inputs.interfaces: - interface_data = _get_interface_data( - interface=interface.name, - vrf=interface.vrf, - command_output=command_output, - ) - # Check for correct VRF - if interface_data is not None: - interface_type = get_value(dictionary=interface_data, key="interfaceType", default="unset") - # Check for interfaceType - if interface.mode == "point-to-point" and interface.mode != interface_type: - self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in {interface.mode} reporting {interface_type}") - # Check for passive - elif interface.mode == "passive": - json_path = f"intfLevels.{interface.level}.passive" - if interface_data is None or get_value(dictionary=interface_data, key=json_path, default=False) is False: - self.result.is_failure(f"Interface {interface.name} in VRF {interface.vrf} is not running in passive mode") - else: - self.result.is_failure(f"Interface {interface.name} not found in VRF {interface.vrf}") + interface_detail = {} + vrf_instances = get_value(command_output, f"{interface.vrf}..isisInstances", default={}, separator="..") + for instance_data in vrf_instances.values(): + if interface_data := get_value(instance_data, f"interfaces..{interface.name}", separator=".."): + interface_detail = interface_data + # An interface can only be configured in one IS-IS instance at a time + break + + if not interface_detail: + self.result.is_failure(f"{interface} - Not configured") + continue + + # Check for passive + if interface.mode == "passive": + if get_value(interface_detail, f"intfLevels.{interface.level}.passive", default=False) is False: + self.result.is_failure(f"{interface} - Not running in passive mode") + + # Check for point-to-point or broadcast + elif interface.mode != (interface_type := get_value(interface_detail, "interfaceType", default="unset")): + self.result.is_failure(f"{interface} - Incorrect interface mode - Expected: {interface.mode} Actual: {interface_type}") class VerifyISISSegmentRoutingAdjacencySegments(AntaTest): - """Verify that all expected Adjacency segments are correctly visible for each interface. + """Verifies IS-IS segment routing adjacency segments. + + !!! warning "IS-IS SR Limitation" + As of EOS 4.33.1F, IS-IS SR is supported only in the default VRF. + Please refer to the IS-IS Segment Routing [documentation](https://www.arista.com/en/support/toi/eos-4-17-0f/13789-isis-segment-routing) + for more information. Expected Results ---------------- - * Success: The test will pass if all listed interfaces have correct adjacencies. - * Failure: The test will fail if any of the listed interfaces has not expected list of adjacencies. - * Skipped: The test will be skipped if no ISIS SR Adjacency is found. + * Success: The test will pass if all provided IS-IS instances have the correct adjacency segments. + * Failure: The test will fail if any of the provided IS-IS instances have no adjacency segments or incorrect segments. + * Skipped: The test will be skipped if IS-IS is not configured. Examples -------- @@ -358,91 +245,62 @@ class VerifyISISSegmentRoutingAdjacencySegments(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyISISSegmentRoutingAdjacencySegments test.""" - instances: list[IsisInstance] - - class IsisInstance(BaseModel): - """ISIS Instance model definition.""" - - name: str - """ISIS instance name.""" - vrf: str = "default" - """VRF name where ISIS instance is configured.""" - segments: list[Segment] - """List of Adjacency segments configured in this instance.""" + instances: list[ISISInstance] + """List of IS-IS instances with their information.""" + IsisInstance: ClassVar[type[IsisInstance]] = IsisInstance - class Segment(BaseModel): - """Segment model definition.""" - - interface: Interface - """Interface name to check.""" - level: Literal[1, 2] = 2 - """ISIS level configured for interface. Default is 2.""" - sid_origin: Literal["dynamic"] = "dynamic" - """Adjacency type""" - address: IPv4Address - """IP address of remote end of segment.""" + @field_validator("instances") + @classmethod + def validate_instances(cls, instances: list[ISISInstance]) -> list[ISISInstance]: + """Validate that 'vrf' field is 'default' in each IS-IS instance.""" + for instance in instances: + if instance.vrf != "default": + msg = f"{instance} 'vrf' field must be 'default'" + raise ValueError(msg) + return instances @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyISISSegmentRoutingAdjacencySegments.""" - command_output = self.instance_commands[0].json_output self.result.is_success() - if len(command_output["vrfs"]) == 0: - self.result.is_skipped("IS-IS is not configured on device") + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") return - # initiate defaults - failure_message = [] - skip_vrfs = [] - skip_instances = [] - - # Check if VRFs and instances are present in output. - for instance in self.inputs.instances: - vrf_data = get_value( - dictionary=command_output, - key=f"vrfs.{instance.vrf}", - default=None, - ) - if vrf_data is None: - skip_vrfs.append(instance.vrf) - failure_message.append(f"VRF {instance.vrf} is not configured to run segment routging.") - - elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None: - skip_instances.append(instance.name) - failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.") - - # Check Adjacency segments for instance in self.inputs.instances: - if instance.vrf not in skip_vrfs and instance.name not in skip_instances: - for input_segment in instance.segments: - eos_segment = _get_adjacency_segment_data_by_neighbor( - neighbor=str(input_segment.address), - instance=instance.name, - vrf=instance.vrf, - command_output=command_output, - ) - if eos_segment is None: - failure_message.append(f"Your segment has not been found: {input_segment}.") - - elif ( - eos_segment["localIntf"] != input_segment.interface - or eos_segment["level"] != input_segment.level - or eos_segment["sidOrigin"] != input_segment.sid_origin - ): - failure_message.append(f"Your segment is not correct: Expected: {input_segment} - Found: {eos_segment}.") - if failure_message: - self.result.is_failure("\n".join(failure_message)) + if not (act_segments := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}..adjacencySegments", default=[], separator="..")): + self.result.is_failure(f"{instance} - No adjacency segments found") + continue + + for segment in instance.segments: + if (act_segment := get_item(act_segments, "ipAddress", str(segment.address))) is None: + self.result.is_failure(f"{instance} {segment} - Adjacency segment not found") + continue + + # Check SID origin + if (act_origin := act_segment["sidOrigin"]) != segment.sid_origin: + self.result.is_failure(f"{instance} {segment} - Incorrect SID origin - Expected: {segment.sid_origin} Actual: {act_origin}") + + # Check IS-IS level + if (actual_level := act_segment["level"]) != segment.level: + self.result.is_failure(f"{instance} {segment} - Incorrect IS-IS level - Expected: {segment.level} Actual: {actual_level}") class VerifyISISSegmentRoutingDataplane(AntaTest): - """Verify dataplane of a list of ISIS-SR instances. + """Verifies IS-IS segment routing data-plane configuration. + + !!! warning "IS-IS SR Limitation" + As of EOS 4.33.1F, IS-IS SR is supported only in the default VRF. + Please refer to the IS-IS Segment Routing [documentation](https://www.arista.com/en/support/toi/eos-4-17-0f/13789-isis-segment-routing) + for more information. Expected Results ---------------- - * Success: The test will pass if all instances have correct dataplane configured - * Failure: The test will fail if one of the instances has incorrect dataplane configured - * Skipped: The test will be skipped if ISIS is not running + * Success: The test will pass if all provided IS-IS instances have the correct data-plane configured. + * Failure: The test will fail if any of the provided IS-IS instances have an incorrect data-plane configured. + * Skipped: The test will be skipped if IS-IS is not configured. Examples -------- @@ -463,57 +321,37 @@ class VerifyISISSegmentRoutingDataplane(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyISISSegmentRoutingDataplane test.""" - instances: list[IsisInstance] - - class IsisInstance(BaseModel): - """ISIS Instance model definition.""" + instances: list[ISISInstance] + """List of IS-IS instances with their information.""" + IsisInstance: ClassVar[type[IsisInstance]] = IsisInstance - name: str - """ISIS instance name.""" - vrf: str = "default" - """VRF name where ISIS instance is configured.""" - dataplane: Literal["MPLS", "mpls", "unset"] = "MPLS" - """Configured dataplane for the instance.""" + @field_validator("instances") + @classmethod + def validate_instances(cls, instances: list[ISISInstance]) -> list[ISISInstance]: + """Validate that 'vrf' field is 'default' in each IS-IS instance.""" + for instance in instances: + if instance.vrf != "default": + msg = f"{instance} 'vrf' field must be 'default'" + raise ValueError(msg) + return instances @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyISISSegmentRoutingDataplane.""" - command_output = self.instance_commands[0].json_output self.result.is_success() - if len(command_output["vrfs"]) == 0: - self.result.is_skipped("IS-IS-SR is not running on device.") + # Verify if IS-IS is configured + if not (command_output := self.instance_commands[0].json_output["vrfs"]): + self.result.is_skipped("IS-IS not configured") return - # initiate defaults - failure_message = [] - skip_vrfs = [] - skip_instances = [] - - # Check if VRFs and instances are present in output. for instance in self.inputs.instances: - vrf_data = get_value( - dictionary=command_output, - key=f"vrfs.{instance.vrf}", - default=None, - ) - if vrf_data is None: - skip_vrfs.append(instance.vrf) - failure_message.append(f"VRF {instance.vrf} is not configured to run segment routing.") - - elif get_value(dictionary=vrf_data, key=f"isisInstances.{instance.name}", default=None) is None: - skip_instances.append(instance.name) - failure_message.append(f"Instance {instance.name} is not found in vrf {instance.vrf}.") - - # Check Adjacency segments - for instance in self.inputs.instances: - if instance.vrf not in skip_vrfs and instance.name not in skip_instances: - eos_dataplane = get_value(dictionary=command_output, key=f"vrfs.{instance.vrf}.isisInstances.{instance.name}.dataPlane", default=None) - if instance.dataplane.upper() != eos_dataplane: - failure_message.append(f"ISIS instance {instance.name} is not running dataplane {instance.dataplane} ({eos_dataplane})") + if not (instance_data := get_value(command_output, f"{instance.vrf}..isisInstances..{instance.name}", separator="..")): + self.result.is_failure(f"{instance} - Not configured") + continue - if failure_message: - self.result.is_failure("\n".join(failure_message)) + if instance.dataplane.upper() != (dataplane := instance_data["dataPlane"]): + self.result.is_failure(f"{instance} - Data-plane not correctly configured - Expected: {instance.dataplane.upper()} Actual: {dataplane}") class VerifyISISSegmentRoutingTunnels(AntaTest): @@ -592,9 +430,6 @@ def test(self) -> None: command_output = self.instance_commands[0].json_output self.result.is_success() - # initiate defaults - failure_message = [] - if len(command_output["entries"]) == 0: self.result.is_skipped("IS-IS-SR is not running on device.") return @@ -602,129 +437,31 @@ def test(self) -> None: for input_entry in self.inputs.entries: eos_entry = self._eos_entry_lookup(search_value=input_entry.endpoint, entries=command_output["entries"]) if eos_entry is None: - failure_message.append(f"Tunnel to {input_entry} is not found.") + self.result.is_failure(f"Tunnel to {input_entry.endpoint!s} is not found.") elif input_entry.vias is not None: - failure_src = [] for via_input in input_entry.vias: - if not self._check_tunnel_type(via_input, eos_entry): - failure_src.append("incorrect tunnel type") - if not self._check_tunnel_nexthop(via_input, eos_entry): - failure_src.append("incorrect nexthop") - if not self._check_tunnel_interface(via_input, eos_entry): - failure_src.append("incorrect interface") - if not self._check_tunnel_id(via_input, eos_entry): - failure_src.append("incorrect tunnel ID") - - if failure_src: - failure_message.append(f"Tunnel to {input_entry.endpoint!s} is incorrect: {', '.join(failure_src)}") - - if failure_message: - self.result.is_failure("\n".join(failure_message)) - - def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`. - - Parameters - ---------- - via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias - The input tunnel type to check. - eos_entry : dict[str, Any] - The EOS entry containing the tunnel types. - - Returns - ------- - bool - True if the tunnel type matches any of the tunnel types in `eos_entry`, False otherwise. - """ - if via_input.type is not None: - return any( - via_input.type - == get_value( - dictionary=eos_via, - key="type", - default="undefined", - ) - for eos_via in eos_entry["vias"] - ) - return True - - def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """Check if the tunnel nexthop matches the given input. - - Parameters - ---------- - via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias - The input via object. - eos_entry : dict[str, Any] - The EOS entry dictionary. - - Returns - ------- - bool - True if the tunnel nexthop matches, False otherwise. - """ - if via_input.nexthop is not None: - return any( - str(via_input.nexthop) - == get_value( - dictionary=eos_via, - key="nexthop", - default="undefined", - ) - for eos_via in eos_entry["vias"] - ) - return True - - def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """Check if the tunnel interface exists in the given EOS entry. + 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.") - Parameters - ---------- - via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias - The input via object. - eos_entry : dict[str, Any] - The EOS entry dictionary. - - Returns - ------- - bool - True if the tunnel interface exists, False otherwise. - """ - if via_input.interface is not None: - return any( - via_input.interface - == get_value( - dictionary=eos_via, - key="interface", - default="undefined", - ) - for eos_via in eos_entry["vias"] - ) - return True - - def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias. + def _via_matches(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_via: dict[str, Any]) -> bool: + """Check if the via input matches the eos via. Parameters ---------- via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias - The input vias to check. - eos_entry : dict[str, Any]) - The EOS entry to compare against. + The input via to check. + eos_via : dict[str, Any] + The EOS via to compare against. Returns ------- bool - True if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias, False otherwise. + True if the via input matches the eos via, False otherwise. """ - if via_input.tunnel_id is not None: - return any( - via_input.tunnel_id.upper() - == get_value( - dictionary=eos_via, - key="tunnelId.type", - default="undefined", - ).upper() - for eos_via in eos_entry["vias"] - ) - return True + return ( + (via_input.type is None or via_input.type == eos_via.get("type")) + and (via_input.nexthop is None or str(via_input.nexthop) == eos_via.get("nexthop")) + and (via_input.interface is None or via_input.interface == eos_via.get("interface")) + and (via_input.tunnel_id is None or via_input.tunnel_id.upper() == get_value(eos_via, "tunnelId.type", default="").upper()) + ) diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index 0108d8512..e0aecaf60 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -12,7 +12,7 @@ from pydantic import field_validator from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu -from anta.input_models.snmp import SnmpHost, SnmpSourceInterface, SnmpUser +from anta.input_models.snmp import SnmpGroup, SnmpHost, SnmpSourceInterface, SnmpUser from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -664,3 +664,73 @@ def test(self) -> None: self.result.is_failure(f"{interface_details} - Not configured") elif actual_interface != interface_details.interface: self.result.is_failure(f"{interface_details} - Incorrect source interface - Actual: {actual_interface}") + + +class VerifySnmpGroup(AntaTest): + """Verifies the SNMP group configurations for specified version(s). + + This test performs the following checks: + + 1. Verifies that the SNMP group is configured for the specified version. + 2. For SNMP version 3, verify that the security model matches the expected value. + 3. Ensures that SNMP group configurations, including read, write, and notify views, align with version-specific requirements. + + Expected Results + ---------------- + * Success: The test will pass if the provided SNMP group and all specified parameters are correctly configured. + * Failure: The test will fail if the provided SNMP group is not configured or if any specified parameter is not correctly configured. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpGroup: + snmp_groups: + - group_name: Group1 + version: v1 + read_view: group_read_1 + write_view: group_write_1 + notify_view: group_notify_1 + - group_name: Group2 + version: v3 + read_view: group_read_2 + write_view: group_write_2 + notify_view: group_notify_2 + authentication: priv + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp group", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpGroup test.""" + + snmp_groups: list[SnmpGroup] + """List of SNMP groups.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpGroup.""" + self.result.is_success() + for group in self.inputs.snmp_groups: + # Verify SNMP group details. + if not (group_details := get_value(self.instance_commands[0].json_output, f"groups.{group.group_name}.versions.{group.version}")): + self.result.is_failure(f"{group} - Not configured") + continue + + view_types = [view_type for view_type in ["read", "write", "notify"] if getattr(group, f"{view_type}_view")] + # Verify SNMP views, the read, write and notify settings aligning with version-specific requirements. + for view_type in view_types: + expected_view = getattr(group, f"{view_type}_view") + # Verify actual view is configured. + if group_details.get(f"{view_type}View") == "": + self.result.is_failure(f"{group} View: {view_type} - Not configured") + elif (act_view := group_details.get(f"{view_type}View")) != expected_view: + self.result.is_failure(f"{group} - Incorrect {view_type.title()} view - Expected: {expected_view}, Actual: {act_view}") + elif not group_details.get(f"{view_type}ViewConfig"): + self.result.is_failure(f"{group}, {view_type.title()} View: {expected_view} - Not configured") + + # For version v3, verify that the security model aligns with the expected value. + if group.version == "v3" and (actual_auth := group_details.get("secModel")) != group.authentication: + self.result.is_failure(f"{group} - Incorrect security model - Expected: {group.authentication}, Actual: {actual_auth}") diff --git a/docs/api/tests/routing.isis.md b/docs/api/tests/routing.isis.md index 90e6d25d9..160472fdc 100644 --- a/docs/api/tests/routing.isis.md +++ b/docs/api/tests/routing.isis.md @@ -7,6 +7,8 @@ anta_title: ANTA catalog for IS-IS tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.routing.isis options: @@ -20,3 +22,18 @@ anta_title: ANTA catalog for IS-IS tests - "!test" - "!render" - "!^_[^_]" + +# Input models + +::: anta.input_models.routing.isis + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + anta_hide_test_module_description: true + merge_init_into_class: false + show_labels: true + filters: + - "!^__init__" + - "!^__str__" diff --git a/examples/tests.yaml b/examples/tests.yaml index b190ec3ce..7a9a61a75 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -397,8 +397,6 @@ anta.tests.routing.bgp: advertised_routes: - 192.0.255.1/32 - 192.0.254.5/32 - received_routes: - - 192.0.254.3/32 - VerifyBGPNlriAcceptance: # Verifies that all received NLRI are accepted for all AFI/SAFI configured for BGP IPv4 peer(s). bgp_peers: @@ -456,7 +454,8 @@ anta.tests.routing.bgp: vrf: default strict: False capabilities: - - ipv4Unicast + - ipv4 labeled-Unicast + - ipv4MplsVpn - VerifyBGPPeerRouteLimit: # Verifies maximum routes and warning limit for BGP IPv4 peer(s). bgp_peers: @@ -514,6 +513,12 @@ anta.tests.routing.bgp: - VerifyBGPPeersHealthRibd: # Verifies the health of all the BGP IPv4 peer(s). check_tcp_queues: True + - VerifyBGPRouteECMP: + # Verifies BGP IPv4 route ECMP paths. + route_entries: + - prefix: 10.100.0.128/31 + vrf: default + ecmp_count: 2 - VerifyBGPRoutePaths: # Verifies BGP IPv4 route paths. route_entries: @@ -600,21 +605,18 @@ anta.tests.routing.generic: maximum: 20 anta.tests.routing.isis: - VerifyISISInterfaceMode: - # Verifies interface mode for IS-IS + # Verifies IS-IS interfaces are running in the correct mode. interfaces: - name: Loopback0 mode: passive - # vrf is set to default by default - name: Ethernet2 mode: passive level: 2 - # vrf is set to default by default - name: Ethernet1 mode: point-to-point - vrf: default - # level is set to 2 by default + vrf: PROD - VerifyISISNeighborCount: - # Verifies number of IS-IS neighbors per level and per interface. + # Verifies the number of IS-IS neighbors per interface and level. interfaces: - name: Ethernet1 level: 1 @@ -624,11 +626,11 @@ anta.tests.routing.isis: count: 1 - name: Ethernet3 count: 2 - # level is set to 2 by default - VerifyISISNeighborState: - # Verifies all IS-IS neighbors are in UP state. + # Verifies the health of IS-IS neighbors. + check_all_vrfs: true - VerifyISISSegmentRoutingAdjacencySegments: - # Verify that all expected Adjacency segments are correctly visible for each interface. + # Verifies IS-IS segment routing adjacency segments. instances: - name: CORE-ISIS vrf: default @@ -637,7 +639,7 @@ anta.tests.routing.isis: address: 10.0.1.3 sid_origin: dynamic - VerifyISISSegmentRoutingDataplane: - # Verify dataplane of a list of ISIS-SR instances. + # Verifies IS-IS segment routing data-plane configuration. instances: - name: CORE-ISIS vrf: default @@ -784,6 +786,20 @@ anta.tests.snmp: # Verifies the SNMP error counters. error_counters: - inVersionErrs + - VerifySnmpGroup: + # Verifies the SNMP group configurations for specified version(s). + snmp_groups: + - group_name: Group1 + version: v1 + read_view: group_read_1 + write_view: group_write_1 + notify_view: group_notify_1 + - group_name: Group2 + version: v3 + read_view: group_read_2 + write_view: group_write_2 + notify_view: group_notify_2 + authentication: priv - VerifySnmpHostLogging: # Verifies SNMP logging configurations. hosts: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 21758ea78..418c2d0e1 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, + VerifyBGPRouteECMP, VerifyBgpRouteMaps, VerifyBGPRoutePaths, VerifyBGPSpecificPeers, @@ -1110,6 +1111,139 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo }, "expected": {"result": "success"}, }, + { + "name": "success-advertised-route-validation-only", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ], + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": True, + }, + } + ], + }, + }, + } + } + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "advertised_routes": ["192.0.254.5/32", "192.0.254.3/32"], + }, + { + "peer_address": "172.30.11.5", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32", "192.0.254.5/32"], + }, + ] + }, + "expected": {"result": "success"}, + }, { "name": "failure-no-routes", "test": VerifyBGPExchangedRoutes, @@ -1304,12 +1438,157 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo "peer_address": "172.30.11.1", "vrf": "default", "advertised_routes": ["192.0.254.3/32", "192.0.254.51/32"], - "received_routes": ["192.0.254.31/32", "192.0.255.4/32"], + "received_routes": ["192.0.254.31/32", "192.0.255.4/32"], + }, + { + "peer_address": "172.30.11.5", + "vrf": "default", + "advertised_routes": ["192.0.254.31/32", "192.0.254.5/32"], + "received_routes": ["192.0.254.3/32", "192.0.255.41/32"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.3/32 - Valid: False, Active: True", + "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.51/32 - Not found", + "Peer: 172.30.11.1 VRF: default Received route: 192.0.254.31/32 - Not found", + "Peer: 172.30.11.1 VRF: default Received route: 192.0.255.4/32 - Valid: False, Active: False", + "Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.31/32 - Not found", + "Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.5/32 - Valid: False, Active: True", + "Peer: 172.30.11.5 VRF: default Received route: 192.0.254.3/32 - Valid: True, Active: False", + "Peer: 172.30.11.5 VRF: default Received route: 192.0.255.41/32 - Not found", + ], + }, + }, + { + "name": "failure-invalid-or-inactive-routes-as-per-given-input", + "test": VerifyBGPExchangedRoutes, + "eos_data": [ + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": False, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + "192.0.254.5/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ] + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": True, + }, + } + ], + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "bgpRouteEntries": { + "192.0.254.3/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ], + }, + "192.0.255.4/32": { + "bgpRoutePaths": [ + { + "routeType": { + "valid": True, + "active": False, + }, + } + ], + }, + }, + } + } + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "172.30.11.1", + "vrf": "default", + "advertised_routes": ["192.0.254.3/32", "192.0.254.51/32"], }, { "peer_address": "172.30.11.5", "vrf": "default", - "advertised_routes": ["192.0.254.31/32", "192.0.254.5/32"], "received_routes": ["192.0.254.3/32", "192.0.255.41/32"], }, ] @@ -1319,10 +1598,6 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo "messages": [ "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.3/32 - Valid: False, Active: True", "Peer: 172.30.11.1 VRF: default Advertised route: 192.0.254.51/32 - Not found", - "Peer: 172.30.11.1 VRF: default Received route: 192.0.254.31/32 - Not found", - "Peer: 172.30.11.1 VRF: default Received route: 192.0.255.4/32 - Valid: False, Active: False", - "Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.31/32 - Not found", - "Peer: 172.30.11.5 VRF: default Advertised route: 192.0.254.5/32 - Valid: False, Active: True", "Peer: 172.30.11.5 VRF: default Received route: 192.0.254.3/32 - Valid: True, Active: False", "Peer: 172.30.11.5 VRF: default Received route: 192.0.255.41/32 - Not found", ], @@ -1384,12 +1659,12 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo { "peer_address": "172.30.11.1", "vrf": "default", - "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"], + "capabilities": ["Ipv4Unicast", "ipv4 Mpls labels"], }, { "peer_address": "172.30.11.10", "vrf": "MGMT", - "capabilities": ["ipv4 Unicast", "ipv4 MplsVpn"], + "capabilities": ["ipv4_Unicast", "ipv4 MplsVpn"], }, ] }, @@ -1441,12 +1716,12 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo { "peer_address": "172.30.11.10", "vrf": "default", - "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"], + "capabilities": ["ipv4Unicast", "l2-vpn-EVPN"], }, { "peer_address": "172.30.11.1", "vrf": "MGMT", - "capabilities": ["ipv4Unicast", "L2 Vpn EVPN"], + "capabilities": ["ipv4Unicast", "l2vpnevpn"], }, ] }, @@ -1575,7 +1850,7 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo { "peer_address": "172.30.11.10", "vrf": "MGMT", - "capabilities": ["ipv4unicast", "ipv4 mplsvpn", "L2vpnEVPN"], + "capabilities": ["ipv4_unicast", "ipv4 mplsvpn", "L2vpnEVPN"], }, { "peer_address": "172.30.11.11", @@ -1656,13 +1931,13 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo "peer_address": "172.30.11.1", "vrf": "default", "strict": True, - "capabilities": ["Ipv4 Unicast", "ipv4 Mpls labels"], + "capabilities": ["Ipv4 Unicast", "ipv4MplsLabels"], }, { "peer_address": "172.30.11.10", "vrf": "MGMT", "strict": True, - "capabilities": ["ipv4 Unicast", "ipv4 MplsVpn"], + "capabilities": ["ipv4-Unicast", "ipv4MplsVpn"], }, ] }, @@ -5100,4 +5375,417 @@ def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bo "messages": ["Prefix: 10.100.0.128/31 VRF: default - prefix not found", "Prefix: 10.100.0.130/31 VRF: MGMT - prefix not found"], }, }, + { + "name": "success", + "test": VerifyBGPRouteECMP, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.111.254.1", + "asn": "65101", + "bgpRouteEntries": { + "10.111.134.0/24": { + "address": "10.111.134.0", + "maskLength": 24, + "bgpRoutePaths": [ + { + "nextHop": "10.111.2.0", + "routeType": { + "valid": True, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.111.1.0", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": True, + "ecmpContributor": True, + }, + }, + ], + "totalPaths": 2, + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "routes": { + "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]}, + "10.111.134.0/24": { + "routeType": "eBGP", + "vias": [ + {"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, + {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}, + ], + "directlyConnected": False, + }, + }, + } + } + }, + ], + "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]}, + "expected": {"result": "success"}, + }, + { + "name": "failure-prefix-not-found-bgp-table", + "test": VerifyBGPRouteECMP, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.111.254.1", + "asn": "65101", + "bgpRouteEntries": { + "10.111.134.0/24": { + "address": "10.111.134.0", + "maskLength": 24, + "bgpRoutePaths": [ + { + "nextHop": "10.111.1.0", + "routeType": { + "valid": True, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.111.2.0", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.255.255.2", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": False, + "ecmpContributor": False, + }, + }, + ], + "totalPaths": 3, + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "routes": { + "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]}, + "10.111.134.0/24": { + "routeType": "eBGP", + "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}], + "directlyConnected": False, + }, + }, + } + } + }, + ], + "inputs": {"route_entries": [{"prefix": "10.111.124.0/24", "vrf": "default", "ecmp_count": 2}]}, + "expected": {"result": "failure", "messages": ["Prefix: 10.111.124.0/24 VRF: default - prefix not found in BGP table"]}, + }, + { + "name": "failure-valid-active-ecmp-head-not-found", + "test": VerifyBGPRouteECMP, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.111.254.1", + "asn": "65101", + "bgpRouteEntries": { + "10.111.134.0/24": { + "address": "10.111.134.0", + "maskLength": 24, + "bgpRoutePaths": [ + { + "nextHop": "10.111.1.0", + "routeType": { + "valid": False, + "active": True, + "ecmpHead": False, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.111.2.0", + "routeType": { + "valid": False, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.255.255.2", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": False, + "ecmpContributor": False, + }, + }, + ], + "totalPaths": 3, + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "routes": { + "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]}, + "10.111.134.0/24": { + "routeType": "eBGP", + "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}], + "directlyConnected": False, + }, + }, + } + } + }, + ], + "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]}, + "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - valid and active ECMP head not found"]}, + }, + { + "name": "failure-ecmp-count-mismatch", + "test": VerifyBGPRouteECMP, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.111.254.1", + "asn": "65101", + "bgpRouteEntries": { + "10.111.134.0/24": { + "address": "10.111.134.0", + "maskLength": 24, + "bgpRoutePaths": [ + { + "nextHop": "10.111.1.0", + "routeType": { + "valid": True, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.111.2.0", + "routeType": { + "valid": False, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.255.255.2", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": False, + "ecmpContributor": False, + }, + }, + ], + "totalPaths": 3, + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "routes": { + "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]}, + "10.111.134.0/24": { + "routeType": "eBGP", + "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}], + "directlyConnected": False, + }, + }, + } + } + }, + ], + "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]}, + "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - ECMP count mismatch - Expected: 2, Actual: 1"]}, + }, + { + "name": "failure-prefix-not-found-routing-table", + "test": VerifyBGPRouteECMP, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.111.254.1", + "asn": "65101", + "bgpRouteEntries": { + "10.111.134.0/24": { + "address": "10.111.134.0", + "maskLength": 24, + "bgpRoutePaths": [ + { + "nextHop": "10.111.1.0", + "routeType": { + "valid": True, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.111.2.0", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.255.255.2", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": False, + "ecmpContributor": False, + }, + }, + ], + "totalPaths": 3, + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "routes": { + "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]}, + "10.111.114.0/24": { + "routeType": "eBGP", + "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}, {"nexthopAddr": "10.111.2.0", "interface": "Ethernet3"}], + "directlyConnected": False, + }, + }, + } + } + }, + ], + "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]}, + "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - prefix not found in routing table"]}, + }, + { + "name": "failure-nexthops-mismatch", + "test": VerifyBGPRouteECMP, + "eos_data": [ + { + "vrfs": { + "default": { + "vrf": "default", + "routerId": "10.111.254.1", + "asn": "65101", + "bgpRouteEntries": { + "10.111.134.0/24": { + "address": "10.111.134.0", + "maskLength": 24, + "bgpRoutePaths": [ + { + "nextHop": "10.111.1.0", + "routeType": { + "valid": True, + "active": True, + "ecmpHead": True, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.111.2.0", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": True, + "ecmpContributor": True, + }, + }, + { + "nextHop": "10.255.255.2", + "routeType": { + "valid": True, + "active": False, + "ecmpHead": False, + "ecmp": False, + "ecmpContributor": False, + }, + }, + ], + "totalPaths": 3, + }, + }, + } + } + }, + { + "vrfs": { + "default": { + "routes": { + "10.111.112.0/24": {"routeType": "eBGP", "vias": [{"interface": "Vlan112"}]}, + "10.111.134.0/24": { + "routeType": "eBGP", + "vias": [{"nexthopAddr": "10.111.1.0", "interface": "Ethernet2"}], + "directlyConnected": False, + }, + }, + } + } + }, + ], + "inputs": {"route_entries": [{"prefix": "10.111.134.0/24", "vrf": "default", "ecmp_count": 2}]}, + "expected": {"result": "failure", "messages": ["Prefix: 10.111.134.0/24 VRF: default - Nexthops count mismatch - BGP: 2, RIB: 1"]}, + }, ] diff --git a/tests/units/anta_tests/routing/test_isis.py b/tests/units/anta_tests/routing/test_isis.py index 9c379eae3..733f5710b 100644 --- a/tests/units/anta_tests/routing/test_isis.py +++ b/tests/units/anta_tests/routing/test_isis.py @@ -18,13 +18,12 @@ VerifyISISSegmentRoutingAdjacencySegments, VerifyISISSegmentRoutingDataplane, VerifyISISSegmentRoutingTunnels, - _get_interface_data, ) from tests.units.anta_tests import test DATA: list[dict[str, Any]] = [ { - "name": "success only default vrf", + "name": "success-default-vrf", "test": VerifyISISNeighborState, "eos_data": [ { @@ -60,7 +59,27 @@ } } } - } + }, + "customer": { + "isisInstances": { + "CORE-ISIS": { + "neighbors": { + "0168.0000.0112": { + "adjacencies": [ + { + "hostname": "s1-p02", + "circuitId": "87", + "interfaceName": "Ethernet2", + "state": "down", + "lastHelloTime": 1713688405, + "routerIdV4": "1.0.0.112", + } + ] + } + } + } + } + }, } }, ], @@ -68,7 +87,7 @@ "expected": {"result": "success"}, }, { - "name": "success different vrfs", + "name": "success-multiple-vrfs", "test": VerifyISISNeighborState, "eos_data": [ { @@ -92,31 +111,31 @@ }, }, }, - "customer": { - "isisInstances": { - "CORE-ISIS": { - "neighbors": { - "0168.0000.0112": { - "adjacencies": [ - { - "hostname": "s1-p02", - "circuitId": "87", - "interfaceName": "Ethernet2", - "state": "up", - "lastHelloTime": 1713688405, - "routerIdV4": "1.0.0.112", - } - ] - } + }, + "customer": { + "isisInstances": { + "CORE-ISIS": { + "neighbors": { + "0168.0000.0112": { + "adjacencies": [ + { + "hostname": "s1-p02", + "circuitId": "87", + "interfaceName": "Ethernet2", + "state": "up", + "lastHelloTime": 1713688405, + "routerIdV4": "1.0.0.112", + } + ] } } } - }, - } + } + }, } }, ], - "inputs": None, + "inputs": {"check_all_vrfs": True}, "expected": {"result": "success"}, }, { @@ -163,23 +182,101 @@ "inputs": None, "expected": { "result": "failure", - "messages": ["Some neighbors are not in the correct state (UP): [{'vrf': 'default', 'instance': 'CORE-ISIS', 'neighbor': 's1-p01', 'state': 'down'}]."], + "messages": ["Instance: CORE-ISIS VRF: default Interface: Ethernet1 - Adjacency down"], }, }, { - "name": "skipped - no neighbor", + "name": "skipped-not-configured", "test": VerifyISISNeighborState, "eos_data": [ - {"vrfs": {"default": {"isisInstances": {"CORE-ISIS": {"neighbors": {}}}}}}, + {"vrfs": {}}, ], "inputs": None, + "expected": { + "result": "skipped", + "messages": ["IS-IS not configured"], + }, + }, + { + "name": "failure-multiple-vrfs", + "test": VerifyISISNeighborState, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "neighbors": { + "0168.0000.0111": { + "adjacencies": [ + { + "hostname": "s1-p01", + "circuitId": "83", + "interfaceName": "Ethernet1", + "state": "up", + "lastHelloTime": 1713688408, + "routerIdV4": "1.0.0.111", + } + ] + }, + }, + }, + }, + }, + "customer": { + "isisInstances": { + "CORE-ISIS": { + "neighbors": { + "0168.0000.0112": { + "adjacencies": [ + { + "hostname": "s1-p02", + "circuitId": "87", + "interfaceName": "Ethernet2", + "state": "down", + "lastHelloTime": 1713688405, + "routerIdV4": "1.0.0.112", + } + ] + } + } + } + } + }, + } + }, + ], + "inputs": {"check_all_vrfs": True}, + "expected": { + "result": "failure", + "messages": ["Instance: CORE-ISIS VRF: customer Interface: Ethernet2 - Adjacency down"], + }, + }, + { + "name": "skipped-no-neighbor-detected", + "test": VerifyISISNeighborState, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "neighbors": {}, + }, + }, + }, + "customer": {"isisInstances": {"CORE-ISIS": {"neighbors": {}}}}, + } + }, + ], + "inputs": {"check_all_vrfs": True}, "expected": { "result": "skipped", "messages": ["No IS-IS neighbor detected"], }, }, { - "name": "success only default vrf", + "name": "success-default-vrf", "test": VerifyISISNeighborCount, "eos_data": [ { @@ -251,10 +348,108 @@ "expected": {"result": "success"}, }, { - "name": "skipped - no neighbor", + "name": "success-multiple-VRFs", + "test": VerifyISISNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "interfaces": { + "Loopback0": { + "enabled": True, + "intfLevels": { + "2": { + "ipv4Metric": 10, + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": True, + "v4Protection": "disabled", + "v6Protection": "disabled", + } + }, + "areaProxyBoundary": False, + }, + "Ethernet1": { + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "84", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + "Ethernet2": { + "enabled": True, + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "88", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + }, + "PROD": { + "isisInstances": { + "PROD-ISIS": { + "interfaces": { + "Ethernet3": { + "enabled": True, + "intfLevels": { + "1": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "88", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + }, + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet1", "level": 2, "count": 1}, + {"name": "Ethernet2", "level": 2, "count": 1}, + {"name": "Ethernet3", "vrf": "PROD", "level": 1, "count": 1}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "skipped-not-configured", "test": VerifyISISNeighborCount, "eos_data": [ - {"vrfs": {"default": {"isisInstances": {"CORE-ISIS": {"interfaces": {}}}}}}, + {"vrfs": {}}, ], "inputs": { "interfaces": [ @@ -263,12 +458,249 @@ }, "expected": { "result": "skipped", - "messages": ["No IS-IS neighbor detected"], + "messages": ["IS-IS not configured"], + }, + }, + { + "name": "failure-interface-not-configured", + "test": VerifyISISNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "interfaces": { + "Ethernet1": { + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 0, + "linkId": "84", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "level": 2, "count": 1}, + ] + }, + "expected": { + "result": "failure", + "messages": ["Interface: Ethernet2 VRF: default Level: 2 - Not configured"], + }, + }, + { + "name": "success-interface-is-in-wrong-vrf", + "test": VerifyISISNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "interfaces": { + "Ethernet1": { + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "84", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + }, + "PROD": { + "isisInstances": { + "PROD-ISIS": { + "interfaces": { + "Ethernet2": { + "enabled": True, + "intfLevels": { + "1": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "88", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + }, + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "level": 2, "count": 1}, + {"name": "Ethernet1", "vrf": "PROD", "level": 1, "count": 1}, + ] + }, + "expected": { + "result": "failure", + "messages": ["Interface: Ethernet2 VRF: default Level: 2 - Not configured", "Interface: Ethernet1 VRF: PROD Level: 1 - Not configured"], + }, + }, + { + "name": "failure-wrong-count", + "test": VerifyISISNeighborCount, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "interfaces": { + "Ethernet1": { + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 3, + "linkId": "84", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + } + } + }, + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet1", "level": 2, "count": 1}, + ] + }, + "expected": { + "result": "failure", + "messages": ["Interface: Ethernet1 VRF: default Level: 2 - Neighbor count mismatch - Expected: 1 Actual: 3"], + }, + }, + { + "name": "success-default-vrf", + "test": VerifyISISInterfaceMode, + "eos_data": [ + { + "vrfs": { + "default": { + "isisInstances": { + "CORE-ISIS": { + "interfaces": { + "Loopback0": { + "enabled": True, + "index": 2, + "snpa": "0:0:0:0:0:0", + "mtu": 65532, + "interfaceAddressFamily": "ipv4", + "interfaceType": "loopback", + "intfLevels": { + "2": { + "ipv4Metric": 10, + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": True, + "v4Protection": "disabled", + "v6Protection": "disabled", + } + }, + "areaProxyBoundary": False, + }, + "Ethernet1": { + "enabled": True, + "index": 132, + "snpa": "P2P", + "interfaceType": "point-to-point", + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "84", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + "Ethernet2": { + "enabled": True, + "interfaceType": "broadcast", + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 0, + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": True, + "v4Protection": "disabled", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + } + } + } + } + } + } + ], + "inputs": { + "interfaces": [ + {"name": "Loopback0", "mode": "passive"}, + {"name": "Ethernet2", "mode": "passive"}, + {"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"}, + ] }, + "expected": {"result": "success"}, }, { - "name": "failure - missing interface", - "test": VerifyISISNeighborCount, + "name": "success-multiple-VRFs", + "test": VerifyISISInterfaceMode, "eos_data": [ { "vrfs": { @@ -276,11 +708,34 @@ "isisInstances": { "CORE-ISIS": { "interfaces": { + "Loopback0": { + "enabled": True, + "index": 2, + "snpa": "0:0:0:0:0:0", + "mtu": 65532, + "interfaceAddressFamily": "ipv4", + "interfaceType": "loopback", + "intfLevels": { + "2": { + "ipv4Metric": 10, + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": True, + "v4Protection": "disabled", + "v6Protection": "disabled", + } + }, + "areaProxyBoundary": False, + }, "Ethernet1": { + "enabled": True, + "index": 132, + "snpa": "P2P", + "interfaceType": "point-to-point", "intfLevels": { "2": { "ipv4Metric": 10, - "numAdjacencies": 0, + "numAdjacencies": 1, "linkId": "84", "sharedSecretProfile": "", "isisAdjacencies": [], @@ -295,35 +750,20 @@ } } } - } - } - }, - ], - "inputs": { - "interfaces": [ - {"name": "Ethernet2", "level": 2, "count": 1}, - ] - }, - "expected": { - "result": "failure", - "messages": ["No neighbor detected for interface Ethernet2"], - }, - }, - { - "name": "failure - wrong count", - "test": VerifyISISNeighborCount, - "eos_data": [ - { - "vrfs": { - "default": { + }, + "PROD": { "isisInstances": { - "CORE-ISIS": { + "PROD-ISIS": { "interfaces": { - "Ethernet1": { + "Ethernet4": { + "enabled": True, + "index": 132, + "snpa": "P2P", + "interfaceType": "point-to-point", "intfLevels": { "2": { "ipv4Metric": 10, - "numAdjacencies": 3, + "numAdjacencies": 1, "linkId": "84", "sharedSecretProfile": "", "isisAdjacencies": [], @@ -335,25 +775,42 @@ "interfaceSpeed": 1000, "areaProxyBoundary": False, }, + "Ethernet5": { + "enabled": True, + "interfaceType": "broadcast", + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 0, + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": True, + "v4Protection": "disabled", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, } } } - } + }, } - }, + } ], "inputs": { "interfaces": [ - {"name": "Ethernet1", "level": 2, "count": 1}, + {"name": "Loopback0", "mode": "passive"}, + {"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"}, + {"name": "Ethernet4", "mode": "point-to-point", "vrf": "PROD"}, + {"name": "Ethernet5", "mode": "passive", "vrf": "PROD"}, ] }, - "expected": { - "result": "failure", - "messages": ["Interface Ethernet1: expected Level 2: count 1, got Level 2: count 3"], - }, + "expected": {"result": "success"}, }, { - "name": "success VerifyISISInterfaceMode only default vrf", + "name": "failure-interface-not-passive", "test": VerifyISISInterfaceMode, "eos_data": [ { @@ -403,14 +860,14 @@ }, "Ethernet2": { "enabled": True, - "interfaceType": "broadcast", + "interfaceType": "point-to-point", "intfLevels": { "2": { "ipv4Metric": 10, "numAdjacencies": 0, "sharedSecretProfile": "", "isisAdjacencies": [], - "passive": True, + "passive": False, "v4Protection": "disabled", "v6Protection": "disabled", } @@ -432,10 +889,13 @@ {"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"}, ] }, - "expected": {"result": "success"}, + "expected": { + "result": "failure", + "messages": ["Interface: Ethernet2 VRF: default Level: 2 - Not running in passive mode"], + }, }, { - "name": "failure VerifyISISInterfaceMode default vrf with interface not running passive mode", + "name": "failure-interface-not-point-to-point", "test": VerifyISISInterfaceMode, "eos_data": [ { @@ -467,7 +927,7 @@ "enabled": True, "index": 132, "snpa": "P2P", - "interfaceType": "point-to-point", + "interfaceType": "broadcast", "intfLevels": { "2": { "ipv4Metric": 10, @@ -485,14 +945,14 @@ }, "Ethernet2": { "enabled": True, - "interfaceType": "point-to-point", + "interfaceType": "broadcast", "intfLevels": { "2": { "ipv4Metric": 10, "numAdjacencies": 0, "sharedSecretProfile": "", "isisAdjacencies": [], - "passive": False, + "passive": True, "v4Protection": "disabled", "v6Protection": "disabled", } @@ -516,16 +976,16 @@ }, "expected": { "result": "failure", - "messages": ["Interface Ethernet2 in VRF default is not running in passive mode"], + "messages": ["Interface: Ethernet1 VRF: default Level: 2 - Incorrect interface mode - Expected: point-to-point Actual: broadcast"], }, }, { - "name": "failure VerifyISISInterfaceMode default vrf with interface not running point-point mode", + "name": "failure-interface-wrong-vrf", "test": VerifyISISInterfaceMode, "eos_data": [ { "vrfs": { - "default": { + "fake_vrf": { "isisInstances": { "CORE-ISIS": { "interfaces": { @@ -552,7 +1012,7 @@ "enabled": True, "index": 132, "snpa": "P2P", - "interfaceType": "broadcast", + "interfaceType": "point-to-point", "intfLevels": { "2": { "ipv4Metric": 10, @@ -601,16 +1061,33 @@ }, "expected": { "result": "failure", - "messages": ["Interface Ethernet1 in VRF default is not running in point-to-point reporting broadcast"], + "messages": [ + "Interface: Loopback0 VRF: default Level: 2 - Not configured", + "Interface: Ethernet2 VRF: default Level: 2 - Not configured", + "Interface: Ethernet1 VRF: default Level: 2 - Not configured", + ], + }, + }, + { + "name": "skipped-not-configured", + "test": VerifyISISInterfaceMode, + "eos_data": [{"vrfs": {}}], + "inputs": { + "interfaces": [ + {"name": "Loopback0", "mode": "passive"}, + {"name": "Ethernet2", "mode": "passive"}, + {"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"}, + ] }, + "expected": {"result": "skipped", "messages": ["IS-IS not configured"]}, }, { - "name": "failure VerifyISISInterfaceMode default vrf with interface not running correct VRF mode", + "name": "failure-multiple-VRFs", "test": VerifyISISInterfaceMode, "eos_data": [ { "vrfs": { - "fake_vrf": { + "default": { "isisInstances": { "CORE-ISIS": { "interfaces": { @@ -637,7 +1114,7 @@ "enabled": True, "index": 132, "snpa": "P2P", - "interfaceType": "point-to-point", + "interfaceType": "broadcast", "intfLevels": { "2": { "ipv4Metric": 10, @@ -653,7 +1130,35 @@ "interfaceSpeed": 1000, "areaProxyBoundary": False, }, - "Ethernet2": { + } + } + } + }, + "PROD": { + "isisInstances": { + "PROD-ISIS": { + "interfaces": { + "Ethernet4": { + "enabled": True, + "index": 132, + "snpa": "P2P", + "interfaceType": "broadcast", + "intfLevels": { + "2": { + "ipv4Metric": 10, + "numAdjacencies": 1, + "linkId": "84", + "sharedSecretProfile": "", + "isisAdjacencies": [], + "passive": False, + "v4Protection": "link", + "v6Protection": "disabled", + } + }, + "interfaceSpeed": 1000, + "areaProxyBoundary": False, + }, + "Ethernet5": { "enabled": True, "interfaceType": "broadcast", "intfLevels": { @@ -662,7 +1167,7 @@ "numAdjacencies": 0, "sharedSecretProfile": "", "isisAdjacencies": [], - "passive": True, + "passive": False, "v4Protection": "disabled", "v6Protection": "disabled", } @@ -673,41 +1178,29 @@ } } } - } + }, } } ], "inputs": { "interfaces": [ {"name": "Loopback0", "mode": "passive"}, - {"name": "Ethernet2", "mode": "passive"}, {"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"}, + {"name": "Ethernet4", "mode": "point-to-point", "vrf": "PROD"}, + {"name": "Ethernet5", "mode": "passive", "vrf": "PROD"}, ] }, "expected": { "result": "failure", "messages": [ - "Interface Loopback0 not found in VRF default", - "Interface Ethernet2 not found in VRF default", - "Interface Ethernet1 not found in VRF default", + "Interface: Ethernet1 VRF: default Level: 2 - Incorrect interface mode - Expected: point-to-point Actual: broadcast", + "Interface: Ethernet4 VRF: PROD Level: 2 - Incorrect interface mode - Expected: point-to-point Actual: broadcast", + "Interface: Ethernet5 VRF: PROD Level: 2 - Not running in passive mode", ], }, }, { - "name": "skipped VerifyISISInterfaceMode no vrf", - "test": VerifyISISInterfaceMode, - "eos_data": [{"vrfs": {}}], - "inputs": { - "interfaces": [ - {"name": "Loopback0", "mode": "passive"}, - {"name": "Ethernet2", "mode": "passive"}, - {"name": "Ethernet1", "mode": "point-to-point", "vrf": "default"}, - ] - }, - "expected": {"result": "skipped", "messages": ["IS-IS is not configured on device"]}, - }, - { - "name": "Skipped of VerifyISISSegmentRoutingAdjacencySegments no VRF.", + "name": "skipped-not-configured", "test": VerifyISISSegmentRoutingAdjacencySegments, "eos_data": [{"vrfs": {}}], "inputs": { @@ -725,11 +1218,11 @@ } ] }, - "expected": {"result": "skipped", "messages": ["IS-IS is not configured on device"]}, + "expected": {"result": "skipped", "messages": ["IS-IS not configured"]}, }, { "test": VerifyISISSegmentRoutingAdjacencySegments, - "name": "Success of VerifyISISSegmentRoutingAdjacencySegments in default VRF.", + "name": "success", "eos_data": [ { "vrfs": { @@ -807,7 +1300,7 @@ }, { "test": VerifyISISSegmentRoutingAdjacencySegments, - "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments in default VRF for incorrect segment definition.", + "name": "failure-segment-not-found", "eos_data": [ { "vrfs": { @@ -885,12 +1378,12 @@ }, "expected": { "result": "failure", - "messages": ["Your segment has not been found: interface='Ethernet3' level=2 sid_origin='dynamic' address=IPv4Address('10.0.1.2')."], + "messages": ["Instance: CORE-ISIS VRF: default Local Intf: Ethernet3 Adj IP Address: 10.0.1.2 - Adjacency segment not found"], }, }, { "test": VerifyISISSegmentRoutingAdjacencySegments, - "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments with incorrect VRF.", + "name": "failure-no-segments-incorrect-instance", "eos_data": [ { "vrfs": { @@ -949,8 +1442,8 @@ "inputs": { "instances": [ { - "name": "CORE-ISIS", - "vrf": "custom", + "name": "CORE-ISIS2", + "vrf": "default", "segments": [ { "interface": "Ethernet2", @@ -968,12 +1461,12 @@ }, "expected": { "result": "failure", - "messages": ["VRF custom is not configured to run segment routging."], + "messages": ["Instance: CORE-ISIS2 VRF: default - No adjacency segments found"], }, }, { "test": VerifyISISSegmentRoutingAdjacencySegments, - "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments with incorrect Instance.", + "name": "failure-incorrect-segment-level", "eos_data": [ { "vrfs": { @@ -1004,22 +1497,6 @@ }, "level": 2, }, - { - "ipAddress": "10.0.1.1", - "localIntf": "Ethernet1", - "sid": 116385, - "lan": False, - "sidOrigin": "dynamic", - "protection": "unprotected", - "flags": { - "b": False, - "v": True, - "l": True, - "f": False, - "s": False, - }, - "level": 2, - }, ], "receivedGlobalAdjacencySegments": [], "misconfiguredAdjacencySegments": [], @@ -1032,18 +1509,14 @@ "inputs": { "instances": [ { - "name": "CORE-ISIS2", + "name": "CORE-ISIS", "vrf": "default", "segments": [ { "interface": "Ethernet2", "address": "10.0.1.3", "sid_origin": "dynamic", - }, - { - "interface": "Ethernet3", - "address": "10.0.1.2", - "sid_origin": "dynamic", + "level": 1, # Wrong level }, ], } @@ -1051,12 +1524,12 @@ }, "expected": { "result": "failure", - "messages": ["Instance CORE-ISIS2 is not found in vrf default."], + "messages": ["Instance: CORE-ISIS VRF: default Local Intf: Ethernet2 Adj IP Address: 10.0.1.3 - Incorrect IS-IS level - Expected: 1 Actual: 2"], }, }, { "test": VerifyISISSegmentRoutingAdjacencySegments, - "name": "Failure of VerifyISISSegmentRoutingAdjacencySegments with incorrect segment info.", + "name": "failure-incorrect-sid-origin", "eos_data": [ { "vrfs": { @@ -1076,7 +1549,7 @@ "localIntf": "Ethernet2", "sid": 116384, "lan": False, - "sidOrigin": "dynamic", + "sidOrigin": "configured", "protection": "unprotected", "flags": { "b": False, @@ -1106,7 +1579,7 @@ "interface": "Ethernet2", "address": "10.0.1.3", "sid_origin": "dynamic", - "level": 1, # Wrong level + "level": 2, # Wrong level }, ], } @@ -1115,17 +1588,13 @@ "expected": { "result": "failure", "messages": [ - ( - "Your segment is not correct: Expected: interface='Ethernet2' level=1 sid_origin='dynamic' address=IPv4Address('10.0.1.3') - " - "Found: {'ipAddress': '10.0.1.3', 'localIntf': 'Ethernet2', 'sid': 116384, 'lan': False, 'sidOrigin': 'dynamic', 'protection': " - "'unprotected', 'flags': {'b': False, 'v': True, 'l': True, 'f': False, 's': False}, 'level': 2}." - ) + "Instance: CORE-ISIS VRF: default Local Intf: Ethernet2 Adj IP Address: 10.0.1.3 - Incorrect SID origin - Expected: dynamic Actual: configured" ], }, }, { "test": VerifyISISSegmentRoutingDataplane, - "name": "Check VerifyISISSegmentRoutingDataplane is running successfully", + "name": "success", "eos_data": [ { "vrfs": { @@ -1158,7 +1627,7 @@ }, { "test": VerifyISISSegmentRoutingDataplane, - "name": "Check VerifyISISSegmentRoutingDataplane is failing with incorrect dataplane", + "name": "failure-incorrect-dataplane", "eos_data": [ { "vrfs": { @@ -1186,12 +1655,12 @@ }, "expected": { "result": "failure", - "messages": ["ISIS instance CORE-ISIS is not running dataplane unset (MPLS)"], + "messages": ["Instance: CORE-ISIS VRF: default - Data-plane not correctly configured - Expected: UNSET Actual: MPLS"], }, }, { "test": VerifyISISSegmentRoutingDataplane, - "name": "Check VerifyISISSegmentRoutingDataplane is failing for unknown instance", + "name": "failure-instance-not-configured", "eos_data": [ { "vrfs": { @@ -1219,58 +1688,25 @@ }, "expected": { "result": "failure", - "messages": ["Instance CORE-ISIS2 is not found in vrf default."], - }, - }, - { - "test": VerifyISISSegmentRoutingDataplane, - "name": "Check VerifyISISSegmentRoutingDataplane is failing for unknown VRF", - "eos_data": [ - { - "vrfs": { - "default": { - "isisInstances": { - "CORE-ISIS": { - "dataPlane": "MPLS", - "routerId": "1.0.0.11", - "systemId": "0168.0000.0011", - "hostname": "s1-pe01", - } - } - } - } - } - ], - "inputs": { - "instances": [ - { - "name": "CORE-ISIS", - "vrf": "wrong_vrf", - "dataplane": "unset", - }, - ] - }, - "expected": { - "result": "failure", - "messages": ["VRF wrong_vrf is not configured to run segment routing."], + "messages": ["Instance: CORE-ISIS2 VRF: default - Not configured"], }, }, { "test": VerifyISISSegmentRoutingDataplane, - "name": "Check VerifyISISSegmentRoutingDataplane is skipped", + "name": "skipped-not-configured", "eos_data": [{"vrfs": {}}], "inputs": { "instances": [ { "name": "CORE-ISIS", - "vrf": "wrong_vrf", + "vrf": "default", "dataplane": "unset", }, ] }, "expected": { "result": "skipped", - "messages": ["IS-IS-SR is not running on device"], + "messages": ["IS-IS not configured"], }, }, { @@ -1405,7 +1841,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to endpoint=IPv4Network('1.0.0.122/32') vias=None is not found."], + "messages": ["Tunnel to 1.0.0.122/32 is not found."], }, }, { @@ -1486,7 +1922,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.13/32 is incorrect: incorrect tunnel type"], + "messages": ["Tunnel to 1.0.0.13/32 is incorrect."], }, }, { @@ -1574,7 +2010,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is incorrect: incorrect nexthop"], + "messages": ["Tunnel to 1.0.0.122/32 is incorrect."], }, }, { @@ -1662,7 +2098,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is incorrect: incorrect interface"], + "messages": ["Tunnel to 1.0.0.122/32 is incorrect."], }, }, { @@ -1750,7 +2186,7 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.122/32 is incorrect: incorrect nexthop"], + "messages": ["Tunnel to 1.0.0.122/32 is incorrect."], }, }, { @@ -1837,82 +2273,28 @@ }, "expected": { "result": "failure", - "messages": ["Tunnel to 1.0.0.111/32 is incorrect: incorrect tunnel ID"], + "messages": ["Tunnel to 1.0.0.111/32 is incorrect."], }, }, -] - - -COMMAND_OUTPUT = { - "vrfs": { - "default": { - "isisInstances": { - "CORE-ISIS": { - "interfaces": { - "Loopback0": { - "enabled": True, - "intfLevels": { - "2": { - "ipv4Metric": 10, - "sharedSecretProfile": "", - "isisAdjacencies": [], - "passive": True, - "v4Protection": "disabled", - "v6Protection": "disabled", - } - }, - "areaProxyBoundary": False, - }, - "Ethernet1": { - "intfLevels": { - "2": { - "ipv4Metric": 10, - "numAdjacencies": 1, - "linkId": "84", - "sharedSecretProfile": "", - "isisAdjacencies": [], - "passive": False, - "v4Protection": "link", - "v6Protection": "disabled", - } - }, - "interfaceSpeed": 1000, - "areaProxyBoundary": False, - }, - } - } - } + { + "test": VerifyISISSegmentRoutingTunnels, + "name": "skipped with ISIS-SR not running", + "eos_data": [{"entries": {}}], + "inputs": { + "entries": [ + {"endpoint": "1.0.0.122/32"}, + {"endpoint": "1.0.0.13/32", "vias": [{"type": "ip"}]}, + { + "endpoint": "1.0.0.111/32", + "vias": [ + {"type": "tunnel", "tunnel_id": "unset"}, + ], + }, + ] + }, + "expected": { + "result": "skipped", + "messages": ["IS-IS-SR is not running on device."], }, - "EMPTY": {"isisInstances": {}}, - "NO_INTERFACES": {"isisInstances": {"CORE-ISIS": {}}}, - } -} -EXPECTED_LOOPBACK_0_OUTPUT = { - "enabled": True, - "intfLevels": { - "2": { - "ipv4Metric": 10, - "sharedSecretProfile": "", - "isisAdjacencies": [], - "passive": True, - "v4Protection": "disabled", - "v6Protection": "disabled", - } }, - "areaProxyBoundary": False, -} - - -@pytest.mark.parametrize( - ("interface", "vrf", "expected_value"), - [ - pytest.param("Loopback0", "WRONG_VRF", None, id="VRF_not_found"), - pytest.param("Loopback0", "EMPTY", None, id="VRF_no_ISIS_instances"), - pytest.param("Loopback0", "NO_INTERFACES", None, id="ISIS_instance_no_interfaces"), - pytest.param("Loopback42", "default", None, id="interface_not_found"), - pytest.param("Loopback0", "default", EXPECTED_LOOPBACK_0_OUTPUT, id="interface_found"), - ], -) -def test__get_interface_data(interface: str, vrf: str, expected_value: dict[str, Any] | None) -> None: - """Test anta.tests.routing.isis._get_interface_data.""" - assert _get_interface_data(interface, vrf, COMMAND_OUTPUT) == expected_value +] diff --git a/tests/units/anta_tests/test_aaa.py b/tests/units/anta_tests/test_aaa.py index 8589b5955..e88c4e028 100644 --- a/tests/units/anta_tests/test_aaa.py +++ b/tests/units/anta_tests/test_aaa.py @@ -128,7 +128,7 @@ }, ], "inputs": {"servers": ["10.22.10.91", "10.22.10.92"], "vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.92'] are not configured in VRF MGMT"]}, + "expected": {"result": "failure", "messages": ["TACACS servers 10.22.10.92 are not configured in VRF MGMT"]}, }, { "name": "failure-wrong-vrf", @@ -145,7 +145,7 @@ }, ], "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, - "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.91'] are not configured in VRF MGMT"]}, + "expected": {"result": "failure", "messages": ["TACACS servers 10.22.10.91 are not configured in VRF MGMT"]}, }, { "name": "success", @@ -192,7 +192,7 @@ }, ], "inputs": {"groups": ["GROUP1"]}, - "expected": {"result": "failure", "messages": ["TACACS server group(s) ['GROUP1'] are not configured"]}, + "expected": {"result": "failure", "messages": ["TACACS server group(s) GROUP1 are not configured"]}, }, { "name": "success-login-enable", @@ -244,7 +244,7 @@ }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, - "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for login console"]}, + "expected": {"result": "failure", "messages": ["AAA authentication methods group tacacs+, local are not matching for login console"]}, }, { "name": "failure-login-default", @@ -257,7 +257,7 @@ }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, - "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for ['login']"]}, + "expected": {"result": "failure", "messages": ["AAA authentication methods group tacacs+, local are not matching for login"]}, }, { "name": "success", @@ -293,7 +293,7 @@ }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, - "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['commands']"]}, + "expected": {"result": "failure", "messages": ["AAA authorization methods group tacacs+, local are not matching for commands"]}, }, { "name": "failure-exec", @@ -305,7 +305,7 @@ }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, - "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['exec']"]}, + "expected": {"result": "failure", "messages": ["AAA authorization methods group tacacs+, local are not matching for exec"]}, }, { "name": "success-commands-exec-system", @@ -347,7 +347,7 @@ }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, - "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['commands']"]}, + "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for commands"]}, }, { "name": "failure-not-configured-empty", @@ -361,7 +361,7 @@ }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, - "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['system', 'exec', 'commands']"]}, + "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for system, exec, commands"]}, }, { "name": "failure-not-matching", @@ -375,7 +375,7 @@ }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, - "expected": {"result": "failure", "messages": ["AAA accounting default methods ['group tacacs+', 'logging'] are not matching for ['commands']"]}, + "expected": {"result": "failure", "messages": ["AAA accounting default methods group tacacs+, logging are not matching for commands"]}, }, { "name": "success-commands-exec-system", @@ -476,7 +476,7 @@ }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, - "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['commands']"]}, + "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for commands"]}, }, { "name": "failure-not-configured-empty", @@ -490,7 +490,7 @@ }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, - "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['system', 'exec', 'commands']"]}, + "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for system, exec, commands"]}, }, { "name": "failure-not-matching", @@ -522,6 +522,6 @@ }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, - "expected": {"result": "failure", "messages": ["AAA accounting console methods ['group tacacs+', 'logging'] are not matching for ['commands']"]}, + "expected": {"result": "failure", "messages": ["AAA accounting console methods group tacacs+, logging are not matching for commands"]}, }, ] diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index 8b234222f..d809b4aca 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -737,7 +737,7 @@ "result": "failure", "messages": [ "Peer: 192.0.255.7 VRF: default - `isis` routing protocol(s) not configured", - "Peer: 192.0.255.70 VRF: MGMT - `isis` `ospf` routing protocol(s) not configured", + "Peer: 192.0.255.70 VRF: MGMT - `isis`, `ospf` routing protocol(s) not configured", ], }, }, diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index d367258e1..86ada5dea 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -193,7 +193,7 @@ ], }, ], - "expected": {"result": "failure", "messages": ["Host 10.0.0.11 (src: 10.0.0.5, vrf: default, size: 100B, repeat: 2) - Unreachable"]}, + "expected": {"result": "failure", "messages": ["Host: 10.0.0.11 Source: 10.0.0.5 VRF: default - Unreachable"]}, }, { "name": "failure-ipv6", @@ -210,7 +210,7 @@ }, ], "inputs": {"hosts": [{"destination": "fd12:3456:789a:1::2", "source": "fd12:3456:789a:1::1"}]}, - "expected": {"result": "failure", "messages": ["Host fd12:3456:789a:1::2 (src: fd12:3456:789a:1::1, vrf: default, size: 100B, repeat: 2) - Unreachable"]}, + "expected": {"result": "failure", "messages": ["Host: fd12:3456:789a:1::2 Source: fd12:3456:789a:1::1 VRF: default - Unreachable"]}, }, { "name": "failure-interface", @@ -244,7 +244,7 @@ ], }, ], - "expected": {"result": "failure", "messages": ["Host 10.0.0.11 (src: Management0, vrf: default, size: 100B, repeat: 2) - Unreachable"]}, + "expected": {"result": "failure", "messages": ["Host: 10.0.0.11 Source: Management0 VRF: default - Unreachable"]}, }, { "name": "failure-size", @@ -266,7 +266,7 @@ ], }, ], - "expected": {"result": "failure", "messages": ["Host 10.0.0.1 (src: Management0, vrf: default, size: 1501B, repeat: 5, df-bit: enabled) - Unreachable"]}, + "expected": {"result": "failure", "messages": ["Host: 10.0.0.1 Source: Management0 VRF: default - Unreachable"]}, }, { "name": "success", @@ -387,7 +387,7 @@ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, ], }, - "expected": {"result": "failure", "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - Port not found"]}, + "expected": {"result": "failure", "messages": ["Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - Port not found"]}, }, { "name": "failure-no-neighbor", @@ -420,7 +420,7 @@ {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, ], }, - "expected": {"result": "failure", "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - No LLDP neighbors"]}, + "expected": {"result": "failure", "messages": ["Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - No LLDP neighbors"]}, }, { "name": "failure-wrong-neighbor", @@ -469,7 +469,7 @@ }, "expected": { "result": "failure", - "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE2/Ethernet2"], + "messages": ["Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: DC1-SPINE2/Ethernet2"], }, }, { @@ -507,9 +507,9 @@ "expected": { "result": "failure", "messages": [ - "Port Ethernet1 (Neighbor: DC1-SPINE1, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE1/Ethernet2", - "Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - No LLDP neighbors", - "Port Ethernet3 (Neighbor: DC1-SPINE3, Neighbor Port: Ethernet1) - Port not found", + "Port: Ethernet1 Neighbor: DC1-SPINE1 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: DC1-SPINE1/Ethernet2", + "Port: Ethernet2 Neighbor: DC1-SPINE2 Neighbor Port: Ethernet1 - No LLDP neighbors", + "Port: Ethernet3 Neighbor: DC1-SPINE3 Neighbor Port: Ethernet1 - Port not found", ], }, }, @@ -555,7 +555,7 @@ }, "expected": { "result": "failure", - "messages": ["Port Ethernet1 (Neighbor: DC1-SPINE3, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE1/Ethernet1, DC1-SPINE2/Ethernet1"], + "messages": ["Port: Ethernet1 Neighbor: DC1-SPINE3 Neighbor Port: Ethernet1 - Wrong LLDP neighbors: DC1-SPINE1/Ethernet1, DC1-SPINE2/Ethernet1"], }, }, ] diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index b2eee6a05..2c844c717 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -10,6 +10,7 @@ from anta.tests.snmp import ( VerifySnmpContact, VerifySnmpErrorCounters, + VerifySnmpGroup, VerifySnmpHostLogging, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, @@ -792,4 +793,323 @@ ], }, }, + { + "name": "success", + "test": VerifySnmpGroup, + "eos_data": [ + { + "groups": { + "Group1": { + "versions": { + "v1": { + "secModel": "v1", + "readView": "group_read_1", + "readViewConfig": True, + "writeView": "group_write_1", + "writeViewConfig": True, + "notifyView": "group_notify_1", + "notifyViewConfig": True, + } + } + }, + "Group2": { + "versions": { + "v2c": { + "secModel": "v2c", + "readView": "group_read_2", + "readViewConfig": True, + "writeView": "group_write_2", + "writeViewConfig": True, + "notifyView": "group_notify_2", + "notifyViewConfig": True, + } + } + }, + "Group3": { + "versions": { + "v3": { + "secModel": "v3Auth", + "readView": "group_read_3", + "readViewConfig": True, + "writeView": "group_write_3", + "writeViewConfig": True, + "notifyView": "group_notify_3", + "notifyViewConfig": True, + } + } + }, + } + } + ], + "inputs": { + "snmp_groups": [ + {"group_name": "Group1", "version": "v1", "read_view": "group_read_1", "write_view": "group_write_1", "notify_view": "group_notify_1"}, + {"group_name": "Group2", "version": "v2c", "read_view": "group_read_2", "write_view": "group_write_2", "notify_view": "group_notify_2"}, + { + "group_name": "Group3", + "version": "v3", + "read_view": "group_read_3", + "write_view": "group_write_3", + "notify_view": "group_notify_3", + "authentication": "auth", + }, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-incorrect-view", + "test": VerifySnmpGroup, + "eos_data": [ + { + "groups": { + "Group1": { + "versions": { + "v1": { + "secModel": "v1", + "readView": "group_read", + "readViewConfig": True, + "writeView": "group_write", + "writeViewConfig": True, + "notifyView": "group_notify", + "notifyViewConfig": True, + } + } + }, + "Group2": { + "versions": { + "v2c": { + "secModel": "v2c", + "readView": "group_read", + "readViewConfig": True, + "writeView": "group_write", + "writeViewConfig": True, + "notifyView": "group_notify", + "notifyViewConfig": True, + } + } + }, + "Group3": { + "versions": { + "v3": { + "secModel": "v3NoAuth", + "readView": "group_read", + "readViewConfig": True, + "writeView": "group_write", + "writeViewConfig": True, + "notifyView": "group_notify", + "notifyViewConfig": True, + } + } + }, + } + } + ], + "inputs": { + "snmp_groups": [ + {"group_name": "Group1", "version": "v1", "read_view": "group_read_1", "write_view": "group_write_1", "notify_view": "group_notify_1"}, + {"group_name": "Group2", "version": "v2c", "read_view": "group_read_2", "notify_view": "group_notify_2"}, + { + "group_name": "Group3", + "version": "v3", + "read_view": "group_read_3", + "write_view": "group_write_3", + "notify_view": "group_notify_3", + "authentication": "noauth", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Group: Group1, Version: v1 - Incorrect Read view - Expected: group_read_1, Actual: group_read", + "Group: Group1, Version: v1 - Incorrect Write view - Expected: group_write_1, Actual: group_write", + "Group: Group1, Version: v1 - Incorrect Notify view - Expected: group_notify_1, Actual: group_notify", + "Group: Group2, Version: v2c - Incorrect Read view - Expected: group_read_2, Actual: group_read", + "Group: Group2, Version: v2c - Incorrect Notify view - Expected: group_notify_2, Actual: group_notify", + "Group: Group3, Version: v3 - Incorrect Read view - Expected: group_read_3, Actual: group_read", + "Group: Group3, Version: v3 - Incorrect Write view - Expected: group_write_3, Actual: group_write", + "Group: Group3, Version: v3 - Incorrect Notify view - Expected: group_notify_3, Actual: group_notify", + ], + }, + }, + { + "name": "failure-view-config-not-found", + "test": VerifySnmpGroup, + "eos_data": [ + { + "groups": { + "Group1": { + "versions": { + "v1": { + "secModel": "v1", + "readView": "group_read", + "readViewConfig": False, + "writeView": "group_write", + "writeViewConfig": False, + "notifyView": "group_notify", + "notifyViewConfig": False, + } + } + }, + "Group2": { + "versions": { + "v2c": { + "secModel": "v2c", + "readView": "group_read", + "readViewConfig": False, + "writeView": "group_write", + "writeViewConfig": False, + "notifyView": "group_notify", + "notifyViewConfig": False, + } + } + }, + "Group3": { + "versions": { + "v3": { + "secModel": "v3Priv", + "readView": "group_read", + "readViewConfig": False, + "writeView": "group_write", + "writeViewConfig": False, + "notifyView": "group_notify", + "notifyViewConfig": False, + } + } + }, + } + } + ], + "inputs": { + "snmp_groups": [ + {"group_name": "Group1", "version": "v1", "read_view": "group_read", "write_view": "group_write", "notify_view": "group_notify"}, + {"group_name": "Group2", "version": "v2c", "read_view": "group_read", "write_view": "group_write", "notify_view": "group_notify"}, + { + "group_name": "Group3", + "version": "v3", + "write_view": "group_write", + "notify_view": "group_notify", + "authentication": "priv", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Group: Group1, Version: v1, Read View: group_read - Not configured", + "Group: Group1, Version: v1, Write View: group_write - Not configured", + "Group: Group1, Version: v1, Notify View: group_notify - Not configured", + "Group: Group2, Version: v2c, Read View: group_read - Not configured", + "Group: Group2, Version: v2c, Write View: group_write - Not configured", + "Group: Group2, Version: v2c, Notify View: group_notify - Not configured", + "Group: Group3, Version: v3, Write View: group_write - Not configured", + "Group: Group3, Version: v3, Notify View: group_notify - Not configured", + ], + }, + }, + { + "name": "failure-group-version-not-configured", + "test": VerifySnmpGroup, + "eos_data": [ + { + "groups": { + "Group1": {"versions": {"v1": {}}}, + "Group2": {"versions": {"v2c": {}}}, + "Group3": {"versions": {"v3": {}}}, + } + } + ], + "inputs": { + "snmp_groups": [ + {"group_name": "Group1", "version": "v1", "read_view": "group_read_1", "write_view": "group_write_1"}, + {"group_name": "Group2", "version": "v2c", "read_view": "group_read_2", "write_view": "group_write_2", "notify_view": "group_notify_2"}, + { + "group_name": "Group3", + "version": "v3", + "read_view": "group_read_3", + "write_view": "group_write_3", + "notify_view": "group_notify_3", + "authentication": "auth", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Group: Group1, Version: v1 - Not configured", + "Group: Group2, Version: v2c - Not configured", + "Group: Group3, Version: v3 - Not configured", + ], + }, + }, + { + "name": "failure-incorrect-v3-auth-model", + "test": VerifySnmpGroup, + "eos_data": [ + { + "groups": { + "Group3": { + "versions": { + "v3": { + "secModel": "v3Auth", + "readView": "group_read", + "readViewConfig": True, + "writeView": "group_write", + "writeViewConfig": True, + "notifyView": "group_notify", + "notifyViewConfig": True, + } + } + }, + } + } + ], + "inputs": { + "snmp_groups": [ + { + "group_name": "Group3", + "version": "v3", + "read_view": "group_read", + "write_view": "group_write", + "notify_view": "group_notify", + "authentication": "priv", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Group: Group3, Version: v3 - Incorrect security model - Expected: v3Priv, Actual: v3Auth", + ], + }, + }, + { + "name": "failure-view-not-configured", + "test": VerifySnmpGroup, + "eos_data": [ + { + "groups": { + "Group3": {"versions": {"v3": {"secModel": "v3NoAuth", "readView": "group_read", "readViewConfig": True, "writeView": "", "notifyView": ""}}}, + } + } + ], + "inputs": { + "snmp_groups": [ + { + "group_name": "Group3", + "version": "v3", + "read_view": "group_read", + "write_view": "group_write", + "authentication": "noauth", + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Group: Group3, Version: v3 View: write - Not configured", + ], + }, + }, ] diff --git a/tests/units/input_models/routing/test_bgp.py b/tests/units/input_models/routing/test_bgp.py index 7ff047ce4..d8d64507f 100644 --- a/tests/units/input_models/routing/test_bgp.py +++ b/tests/units/input_models/routing/test_bgp.py @@ -11,7 +11,7 @@ import pytest from pydantic import ValidationError -from anta.input_models.routing.bgp import BgpAddressFamily, BgpPeer +from anta.input_models.routing.bgp import BgpAddressFamily, BgpPeer, BgpRoute from anta.tests.routing.bgp import ( VerifyBGPExchangedRoutes, VerifyBGPNlriAcceptance, @@ -19,7 +19,9 @@ VerifyBGPPeerGroup, VerifyBGPPeerMPCaps, VerifyBGPPeerRouteLimit, + VerifyBGPRouteECMP, VerifyBgpRouteMaps, + VerifyBGPRoutePaths, VerifyBGPSpecificPeers, VerifyBGPTimers, ) @@ -118,6 +120,7 @@ class TestVerifyBGPExchangedRoutesInput: [{"peer_address": "172.30.255.5", "vrf": "default", "advertised_routes": ["192.0.254.5/32"], "received_routes": ["192.0.255.4/32"]}], id="valid_both_received_advertised", ), + pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "advertised_routes": ["192.0.254.5/32"]}], id="valid_advertised_routes"), ], ) def test_valid(self, bgp_peers: list[BgpPeer]) -> None: @@ -128,8 +131,6 @@ def test_valid(self, bgp_peers: list[BgpPeer]) -> None: ("bgp_peers"), [ pytest.param([{"peer_address": "172.30.255.5", "vrf": "default"}], id="invalid"), - pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "advertised_routes": ["192.0.254.5/32"]}], id="invalid_received_route"), - pytest.param([{"peer_address": "172.30.255.5", "vrf": "default", "received_routes": ["192.0.254.5/32"]}], id="invalid_advertised_route"), ], ) def test_invalid(self, bgp_peers: list[BgpPeer]) -> None: @@ -288,3 +289,62 @@ def test_invalid(self, bgp_peers: list[BgpPeer]) -> None: """Test VerifyBGPNlriAcceptance.Input invalid inputs.""" with pytest.raises(ValidationError): VerifyBGPNlriAcceptance.Input(bgp_peers=bgp_peers) + + +class TestVerifyBGPRouteECMPInput: + """Test anta.tests.routing.bgp.VerifyBGPRouteECMP.Input.""" + + @pytest.mark.parametrize( + ("bgp_routes"), + [ + pytest.param([{"prefix": "10.100.0.128/31", "vrf": "default", "ecmp_count": 2}], id="valid"), + ], + ) + def test_valid(self, bgp_routes: list[BgpRoute]) -> None: + """Test VerifyBGPRouteECMP.Input valid inputs.""" + VerifyBGPRouteECMP.Input(route_entries=bgp_routes) + + @pytest.mark.parametrize( + ("bgp_routes"), + [ + pytest.param([{"prefix": "10.100.0.128/31", "vrf": "default"}], id="invalid"), + ], + ) + def test_invalid(self, bgp_routes: list[BgpRoute]) -> None: + """Test VerifyBGPRouteECMP.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyBGPRouteECMP.Input(route_entries=bgp_routes) + + +class TestVerifyBGPRoutePathsInput: + """Test anta.tests.routing.bgp.VerifyBGPRoutePaths.Input.""" + + @pytest.mark.parametrize( + ("route_entries"), + [ + pytest.param( + [ + { + "prefix": "10.100.0.128/31", + "vrf": "default", + "paths": [{"nexthop": "10.100.0.10", "origin": "Igp"}, {"nexthop": "10.100.4.5", "origin": "Incomplete"}], + } + ], + id="valid", + ), + ], + ) + def test_valid(self, route_entries: list[BgpRoute]) -> None: + """Test VerifyBGPRoutePaths.Input valid inputs.""" + VerifyBGPRoutePaths.Input(route_entries=route_entries) + + @pytest.mark.parametrize( + ("route_entries"), + [ + pytest.param([{"prefix": "10.100.0.128/31", "vrf": "default"}], id="invalid"), + ], + ) + def test_invalid(self, route_entries: list[BgpRoute]) -> None: + """Test VerifyBGPRoutePaths.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyBGPRoutePaths.Input(route_entries=route_entries) diff --git a/tests/units/input_models/routing/test_isis.py b/tests/units/input_models/routing/test_isis.py new file mode 100644 index 000000000..f22bfa6fd --- /dev/null +++ b/tests/units/input_models/routing/test_isis.py @@ -0,0 +1,70 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Tests for anta.input_models.routing.isis.py.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pydantic import ValidationError + +from anta.tests.routing.isis import VerifyISISSegmentRoutingAdjacencySegments, VerifyISISSegmentRoutingDataplane + +if TYPE_CHECKING: + from anta.input_models.routing.isis import ISISInstance + + +class TestVerifyISISSegmentRoutingAdjacencySegmentsInput: + """Test anta.tests.routing.isis.VerifyISISSegmentRoutingAdjacencySegments.Input.""" + + @pytest.mark.parametrize( + ("instances"), + [ + pytest.param( + [{"name": "CORE-ISIS", "vrf": "default", "segments": [{"interface": "Ethernet2", "address": "10.0.1.3", "sid_origin": "dynamic"}]}], id="valid_vrf" + ), + ], + ) + def test_valid(self, instances: list[ISISInstance]) -> None: + """Test VerifyISISSegmentRoutingAdjacencySegments.Input valid inputs.""" + VerifyISISSegmentRoutingAdjacencySegments.Input(instances=instances) + + @pytest.mark.parametrize( + ("instances"), + [ + pytest.param( + [{"name": "CORE-ISIS", "vrf": "PROD", "segments": [{"interface": "Ethernet2", "address": "10.0.1.3", "sid_origin": "dynamic"}]}], id="invalid_vrf" + ), + ], + ) + def test_invalid(self, instances: list[ISISInstance]) -> None: + """Test VerifyISISSegmentRoutingAdjacencySegments.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyISISSegmentRoutingAdjacencySegments.Input(instances=instances) + + +class TestVerifyISISSegmentRoutingDataplaneInput: + """Test anta.tests.routing.isis.VerifyISISSegmentRoutingDataplane.Input.""" + + @pytest.mark.parametrize( + ("instances"), + [ + pytest.param([{"name": "CORE-ISIS", "vrf": "default", "dataplane": "MPLS"}], id="valid_vrf"), + ], + ) + def test_valid(self, instances: list[ISISInstance]) -> None: + """Test VerifyISISSegmentRoutingDataplane.Input valid inputs.""" + VerifyISISSegmentRoutingDataplane.Input(instances=instances) + + @pytest.mark.parametrize( + ("instances"), + [ + pytest.param([{"name": "CORE-ISIS", "vrf": "PROD", "dataplane": "MPLS"}], id="invalid_vrf"), + ], + ) + def test_invalid(self, instances: list[ISISInstance]) -> None: + """Test VerifyISISSegmentRoutingDataplane.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyISISSegmentRoutingDataplane.Input(instances=instances) diff --git a/tests/units/input_models/test_snmp.py b/tests/units/input_models/test_snmp.py index e48ea301c..8418955e1 100644 --- a/tests/units/input_models/test_snmp.py +++ b/tests/units/input_models/test_snmp.py @@ -11,9 +11,11 @@ import pytest from pydantic import ValidationError +from anta.input_models.snmp import SnmpGroup from anta.tests.snmp import VerifySnmpNotificationHost, VerifySnmpUser if TYPE_CHECKING: + from anta.custom_types import SnmpVersion, SnmpVersionV3AuthType from anta.input_models.snmp import SnmpHost, SnmpUser @@ -163,3 +165,28 @@ def test_invalid(self, notification_hosts: list[SnmpHost]) -> None: """Test VerifySnmpNotificationHost.Input invalid inputs.""" with pytest.raises(ValidationError): VerifySnmpNotificationHost.Input(notification_hosts=notification_hosts) + + +class TestSnmpGroupInput: + """Test anta.input_models.snmp.SnmpGroup.""" + + @pytest.mark.parametrize( + ("group_name", "version", "read_view", "write_view", "notify_view", "authentication"), + [ + pytest.param("group1", "v3", "", "write_1", None, "auth", id="snmp-auth"), + ], + ) + def test_valid(self, group_name: str, read_view: str, version: SnmpVersion, write_view: str, notify_view: str, authentication: SnmpVersionV3AuthType) -> None: + """Test SnmpGroup valid inputs.""" + SnmpGroup(group_name=group_name, version=version, read_view=read_view, write_view=write_view, notify_view=notify_view, authentication=authentication) + + @pytest.mark.parametrize( + ("group_name", "version", "read_view", "write_view", "notify_view", "authentication"), + [ + pytest.param("group1", "v3", "", "write_1", None, None, id="snmp-invalid-auth"), + ], + ) + def test_invalid(self, group_name: str, read_view: str, version: SnmpVersion, write_view: str, notify_view: str, authentication: SnmpVersionV3AuthType) -> None: + """Test SnmpGroup invalid inputs.""" + with pytest.raises(ValidationError): + SnmpGroup(group_name=group_name, version=version, read_view=read_view, write_view=write_view, notify_view=notify_view, authentication=authentication) diff --git a/tests/units/test_custom_types.py b/tests/units/test_custom_types.py index 5a92092c7..c0f3f3a4f 100644 --- a/tests/units/test_custom_types.py +++ b/tests/units/test_custom_types.py @@ -15,11 +15,7 @@ import pytest from anta.custom_types import ( - REGEX_BGP_IPV4_MPLS_VPN, - REGEX_BGP_IPV4_UNICAST, REGEX_TYPE_PORTCHANNEL, - REGEXP_BGP_IPV4_MPLS_LABELS, - REGEXP_BGP_L2VPN_AFI, REGEXP_INTERFACE_ID, REGEXP_PATH_MARKERS, REGEXP_TYPE_EOS_INTERFACE, @@ -29,6 +25,7 @@ bgp_multiprotocol_capabilities_abbreviations, interface_autocomplete, interface_case_sensitivity, + snmp_v3_prefix, validate_regex, ) @@ -50,40 +47,6 @@ def test_regexp_path_markers() -> None: assert re.search(REGEXP_PATH_MARKERS, ".[]?<>") is None -def test_regexp_bgp_l2vpn_afi() -> None: - """Test REGEXP_BGP_L2VPN_AFI.""" - # Test strings that should match the pattern - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpn-evpn") is not None - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2 vpn evpn") is not None - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2-vpn evpn") is not None - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpn evpn") is not None - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpnevpn") is not None - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2 vpnevpn") is not None - - # Test strings that should not match the pattern - assert re.search(REGEXP_BGP_L2VPN_AFI, "al2vpn evpn") is None - assert re.search(REGEXP_BGP_L2VPN_AFI, "l2vpn-evpna") is None - - -def test_regexp_bgp_ipv4_mpls_labels() -> None: - """Test REGEXP_BGP_IPV4_MPLS_LABELS.""" - assert re.search(REGEXP_BGP_IPV4_MPLS_LABELS, "ipv4-mpls-label") is not None - assert re.search(REGEXP_BGP_IPV4_MPLS_LABELS, "ipv4 mpls labels") is not None - assert re.search(REGEXP_BGP_IPV4_MPLS_LABELS, "ipv4Mplslabel") is None - - -def test_regex_bgp_ipv4_mpls_vpn() -> None: - """Test REGEX_BGP_IPV4_MPLS_VPN.""" - assert re.search(REGEX_BGP_IPV4_MPLS_VPN, "ipv4-mpls-vpn") is not None - assert re.search(REGEX_BGP_IPV4_MPLS_VPN, "ipv4_mplsvpn") is None - - -def test_regex_bgp_ipv4_unicast() -> None: - """Test REGEX_BGP_IPV4_UNICAST.""" - assert re.search(REGEX_BGP_IPV4_UNICAST, "ipv4-uni-cast") is not None - assert re.search(REGEX_BGP_IPV4_UNICAST, "ipv4+unicast") is None - - def test_regexp_type_interface_id() -> None: """Test REGEXP_INTERFACE_ID.""" intf_id_re = re.compile(f"{REGEXP_INTERFACE_ID}") @@ -209,13 +172,29 @@ def test_interface_autocomplete_failure() -> None: ("str_input", "expected_output"), [ pytest.param("L2VPNEVPN", "l2VpnEvpn", id="l2VpnEvpn"), - pytest.param("ipv4-mplsLabels", "ipv4MplsLabels", id="ipv4MplsLabels"), + pytest.param("IPv4 Labeled Unicast", "ipv4MplsLabels", id="ipv4MplsLabels"), pytest.param("ipv4-mpls-vpn", "ipv4MplsVpn", id="ipv4MplsVpn"), - pytest.param("ipv4-unicast", "ipv4Unicast", id="ipv4Unicast"), - pytest.param("BLAH", "BLAH", id="unmatched"), + pytest.param("ipv4_unicast", "ipv4Unicast", id="ipv4Unicast"), + pytest.param("ipv4 Mvpn", "ipv4Mvpn", id="ipv4Mvpn"), + pytest.param("ipv4_Flow-Spec Vpn", "ipv4FlowSpecVpn", id="ipv4FlowSpecVpn"), + pytest.param("Dynamic-Path-Selection", "dps", id="dps"), + pytest.param("ipv6unicast", "ipv6Unicast", id="ipv6Unicast"), + pytest.param("IPv4-Multicast", "ipv4Multicast", id="ipv4Multicast"), + pytest.param("IPv6_multicast", "ipv6Multicast", id="ipv6Multicast"), + pytest.param("ipv6_Mpls-Labels", "ipv6MplsLabels", id="ipv6MplsLabels"), + pytest.param("IPv4_SR_TE", "ipv4SrTe", id="ipv4SrTe"), + pytest.param("iPv6-sR-tE", "ipv6SrTe", id="ipv6SrTe"), + pytest.param("ipv6_mpls-vpn", "ipv6MplsVpn", id="ipv6MplsVpn"), + pytest.param("IPv4 Flow-spec", "ipv4FlowSpec", id="ipv4FlowSpec"), + pytest.param("IPv6Flow_spec", "ipv6FlowSpec", id="ipv6FlowSpec"), + pytest.param("ipv6 Flow-Spec Vpn", "ipv6FlowSpecVpn", id="ipv6FlowSpecVpn"), + pytest.param("L2VPN VPLS", "l2VpnVpls", id="l2VpnVpls"), + pytest.param("link-state", "linkState", id="linkState"), + pytest.param("RT_Membership", "rtMembership", id="rtMembership"), + pytest.param("ipv4-RT_Membership", "rtMembership", id="rtMembership"), ], ) -def test_bgp_multiprotocol_capabilities_abbreviationsh(str_input: str, expected_output: str) -> None: +def test_bgp_multiprotocol_capabilities_abbreviations(str_input: str, expected_output: str) -> None: """Test bgp_multiprotocol_capabilities_abbreviations.""" assert bgp_multiprotocol_capabilities_abbreviations(str_input) == expected_output @@ -257,11 +236,7 @@ def test_interface_case_sensitivity_uppercase() -> None: @pytest.mark.parametrize( "str_input", [ - REGEX_BGP_IPV4_MPLS_VPN, - REGEX_BGP_IPV4_UNICAST, REGEX_TYPE_PORTCHANNEL, - REGEXP_BGP_IPV4_MPLS_LABELS, - REGEXP_BGP_L2VPN_AFI, REGEXP_INTERFACE_ID, REGEXP_PATH_MARKERS, REGEXP_TYPE_EOS_INTERFACE, @@ -285,3 +260,10 @@ def test_validate_regex_invalid(str_input: str, error: str) -> None: """Test validate_regex with invalid regex.""" with pytest.raises(ValueError, match=error): validate_regex(str_input) + + +def test_snmp_v3_prefix_valid_input() -> None: + """Test snmp_v3_prefix with valid authentication type.""" + assert snmp_v3_prefix("auth") == "v3Auth" + assert snmp_v3_prefix("noauth") == "v3NoAuth" + assert snmp_v3_prefix("priv") == "v3Priv"