From eee49de8f0d8bf506f6350bc5b418c8a7cb77c5b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:07:25 +0200 Subject: [PATCH 01/20] ci: pre-commit autoupdate (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.2 → v0.5.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.2...v0.5.4) - [github.com/pycqa/pylint: v3.2.5 → v3.2.6](https://github.com/pycqa/pylint/compare/v3.2.5...v3.2.6) - [github.com/pre-commit/mirrors-mypy: v1.10.1 → v1.11.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.1...v1.11.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Guillaume Mulocher --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a257bfd39..1c6282fa8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: name: Run Ruff formatter - repo: https://github.com/pycqa/pylint - rev: "v3.2.5" + rev: "v3.2.6" hooks: - id: pylint name: Check code style with pylint @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.0 hooks: - id: mypy name: Check typing with mypy From 1d89ded2b81d2b5209b0cf872fc76899b6c3a2f2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Jul 2024 09:03:31 +0200 Subject: [PATCH 02/20] bump: pre-commit autoupdate (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.4 → v0.5.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.4...v0.5.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c6282fa8..4e981ae8b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.4 + rev: v0.5.5 hooks: - id: ruff name: Run Ruff linter From f525ffba5867ee42942a2cd398674691d25038b1 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 8 Aug 2024 18:38:28 +0530 Subject: [PATCH 03/20] feat(anta): Refactor VerifyReachability test case to add coverage for DF Bit, packet size (#761) --- anta/tests/connectivity.py | 24 ++++++++++- examples/tests.yaml | 4 ++ tests/units/anta_tests/test_connectivity.py | 44 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py index 06cf8eaeb..c0c6f731b 100644 --- a/anta/tests/connectivity.py +++ b/anta/tests/connectivity.py @@ -33,16 +33,24 @@ class VerifyReachability(AntaTest): - source: Management0 destination: 1.1.1.1 vrf: MGMT + df_bit: True + size: 100 - source: Management0 destination: 8.8.8.8 vrf: MGMT + df_bit: True + size: 100 ``` """ name = "VerifyReachability" description = "Test the network reachability to one or many destination IP(s)." categories: ClassVar[list[str]] = ["connectivity"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}", revision=1)] + # Removing the between '{size}' and '{df_bit}' to compensate the df-bit set default value + # i.e if df-bit kept disable then it will add redundant space in between the command + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1) + ] class Input(AntaTest.Input): """Input model for the VerifyReachability test.""" @@ -61,15 +69,27 @@ class Host(BaseModel): """VRF context. Defaults to `default`.""" repeat: int = 2 """Number of ping repetition. Defaults to 2.""" + size: int = 100 + """Specify datagram size. Defaults to 100.""" + df_bit: bool = False + """Enable do not fragment bit in IP header. Defaults to False.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each host in the input list.""" - return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts] + commands = [] + for host in self.inputs.hosts: + # Enables do not fragment bit in IP header if needed else keeping disable. + # Adding the at start to compensate change in AntaTemplate + df_bit = " df-bit" if host.df_bit else "" + command = template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat, size=host.size, df_bit=df_bit) + commands.append(command) + return commands @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyReachability.""" failures = [] + for command in self.instance_commands: src = command.params.source dst = command.params.destination diff --git a/examples/tests.yaml b/examples/tests.yaml index c0ab625bf..c479c8739 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -98,9 +98,13 @@ anta.tests.connectivity: - source: Management1 destination: 1.1.1.1 vrf: MGMT + df_bit: True + size: 100 - source: Management1 destination: 8.8.8.8 vrf: MGMT + df_bit: True + size: 100 - VerifyLLDPNeighbors: neighbors: - port: Ethernet1 diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index bd3081135..4cc57676c 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -99,6 +99,28 @@ ], "expected": {"result": "success"}, }, + { + "name": "success-df-bit-size", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0", "repeat": 5, "size": 1500, "df_bit": True}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 172.20.20.6 : 1472(1500) bytes of data. + 1480 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.085 ms + 1480 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.020 ms + 1480 bytes from 10.0.0.1: icmp_seq=3 ttl=64 time=0.019 ms + 1480 bytes from 10.0.0.1: icmp_seq=4 ttl=64 time=0.018 ms + 1480 bytes from 10.0.0.1: icmp_seq=5 ttl=64 time=0.017 ms + + --- 10.0.0.1 ping statistics --- + 5 packets transmitted, 5 received, 0% packet loss, time 0ms + rtt min/avg/max/mdev = 0.017/0.031/0.085/0.026 ms, ipg/ewma 0.061/0.057 ms""", + ], + }, + ], + "expected": {"result": "success"}, + }, { "name": "failure-ip", "test": VerifyReachability, @@ -167,6 +189,28 @@ ], "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.11')]"]}, }, + { + "name": "failure-size", + "test": VerifyReachability, + "inputs": {"hosts": [{"destination": "10.0.0.1", "source": "Management0", "repeat": 5, "size": 1501, "df_bit": True}]}, + "eos_data": [ + { + "messages": [ + """PING 10.0.0.1 (10.0.0.1) from 172.20.20.6 : 1473(1501) bytes of data. + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + ping: local error: message too long, mtu=1500 + + --- 10.0.0.1 ping statistics --- + 5 packets transmitted, 0 received, +5 errors, 100% packet loss, time 40ms + """, + ], + }, + ], + "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.1')]"]}, + }, { "name": "success", "test": VerifyLLDPNeighbors, From 6e1e7674b5640d93a0f478777033e1bf29fb8bee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 09:02:47 +0200 Subject: [PATCH 04/20] chore: bump mike from 2.1.2 to 2.1.3 (#788) Bumps [mike](https://github.com/jimporter/mike) from 2.1.2 to 2.1.3. - [Release notes](https://github.com/jimporter/mike/releases) - [Changelog](https://github.com/jimporter/mike/blob/master/CHANGES.md) - [Commits](https://github.com/jimporter/mike/compare/v2.1.2...v2.1.3) --- updated-dependencies: - dependency-name: mike dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8b4feba2..6cafa5b7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dev = [ doc = [ "fontawesome_markdown", "griffe", - "mike==2.1.2", + "mike==2.1.3", "mkdocs-autorefs>=0.4.1", "mkdocs-bootswatch>=1.1", "mkdocs-git-revision-date-localized-plugin>=1.1.0", From 383d48b23165f113aad74a3a814debfe1dd9e6b9 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Wed, 14 Aug 2024 03:03:56 -0400 Subject: [PATCH 05/20] chore: Update VSCode settings.json for Ruff (#787) --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index dd63eea0d..60150c6d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "ruff.enable": true, + "ruff.configuration": "pyproject.toml", "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "pylint.importStrategy": "fromEnvironment", From b60fa6c640bc09bc62b5066498528f216d05c75e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 09:18:33 +0200 Subject: [PATCH 06/20] chore: Bump pre-commit for ruff and mypy (#779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.5 → v0.5.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.5...v0.5.7) - [github.com/pre-commit/mirrors-mypy: v1.11.0 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.0...v1.11.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Thomas Grimonet --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e981ae8b..6f632b99f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.5 + rev: v0.5.7 hooks: - id: ruff name: Run Ruff linter @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.0 + rev: v1.11.1 hooks: - id: mypy name: Check typing with mypy From af5831065963e14a53f6b2a4ead4a7e3014220e6 Mon Sep 17 00:00:00 2001 From: David Lobato Date: Wed, 14 Aug 2024 16:59:41 +0100 Subject: [PATCH 07/20] feat(anta.tests): Optimize VerifyRoutingTableEntry by quering all routes for a vrf. (#682) --- anta/tests/routing/generic.py | 35 +++++-- .../units/anta_tests/routing/test_generic.py | 91 +++++++++++++++++++ 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py index 89d4bc56f..cd9cf0d24 100644 --- a/anta/tests/routing/generic.py +++ b/anta/tests/routing/generic.py @@ -7,7 +7,8 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address, ip_interface +from functools import cache +from ipaddress import IPv4Address, IPv4Interface from typing import ClassVar, Literal from pydantic import model_validator @@ -131,7 +132,10 @@ class VerifyRoutingTableEntry(AntaTest): name = "VerifyRoutingTableEntry" description = "Verifies that the provided routes are present in the routing table of a specified VRF." categories: ClassVar[list[str]] = ["routing"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaTemplate(template="show ip route vrf {vrf} {route}", revision=4), + AntaTemplate(template="show ip route vrf {vrf}", revision=4), + ] class Input(AntaTest.Input): """Input model for the VerifyRoutingTableEntry test.""" @@ -140,20 +144,35 @@ class Input(AntaTest.Input): """VRF context. Defaults to `default` VRF.""" routes: list[IPv4Address] """List of routes to verify.""" + collect: Literal["one", "all"] = "one" + """Route collect behavior: one=one route per command, all=all routes in vrf per command. Defaults to `one`""" def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each route in the input list.""" - return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + """Render the template for the input vrf.""" + if template == VerifyRoutingTableEntry.commands[0] and self.inputs.collect == "one": + return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] + + if template == VerifyRoutingTableEntry.commands[1] and self.inputs.collect == "all": + return [template.render(vrf=self.inputs.vrf)] + + return [] + + @staticmethod + @cache + def ip_interface_ip(route: str) -> IPv4Address: + """Return the IP address of the provided ip route with mask.""" + return IPv4Interface(route).ip @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyRoutingTableEntry.""" - missing_routes = [] + commands_output_route_ips = set() for command in self.instance_commands: - vrf, route = command.params.vrf, command.params.route - if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(next(iter(routes))).ip: - missing_routes.append(str(route)) + command_output_vrf = command.json_output["vrfs"][self.inputs.vrf] + commands_output_route_ips |= {self.ip_interface_ip(route) for route in command_output_vrf["routes"]} + + missing_routes = [str(route) for route in self.inputs.routes if route not in commands_output_route_ips] if not missing_routes: self.result.is_success() diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py index 36658f5b2..621cf22ad 100644 --- a/tests/units/anta_tests/routing/test_generic.py +++ b/tests/units/anta_tests/routing/test_generic.py @@ -130,6 +130,48 @@ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, "expected": {"result": "success"}, }, + { + "name": "success-collect-all", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.1/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], + }, + "10.1.0.2/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + }, + }, + }, + }, + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "all"}, + "expected": {"result": "success"}, + }, { "name": "failure-missing-route", "test": VerifyRoutingTableEntry, @@ -226,4 +268,53 @@ "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]}, }, + { + "name": "failure-wrong-route-collect-all", + "test": VerifyRoutingTableEntry, + "eos_data": [ + { + "vrfs": { + "default": { + "routingDisabled": False, + "allRoutesProgrammedHardware": True, + "allRoutesProgrammedKernel": True, + "defaultRouteState": "notSet", + "routes": { + "10.1.0.1/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], + }, + "10.1.0.55/32": { + "hardwareProgrammed": True, + "routeType": "eBGP", + "routeLeaked": False, + "kernelProgrammed": True, + "routeAction": "forward", + "directlyConnected": False, + "preference": 20, + "metric": 0, + "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], + }, + }, + }, + }, + }, + ], + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "all"}, + "expected": {"result": "failure", "messages": ["The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']"]}, + }, + { + "name": "collect-input-error", + "test": VerifyRoutingTableEntry, + "eos_data": {}, + "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"], "collect": "not-valid"}, + "expected": {"result": "error", "messages": ["Inputs are not valid"]}, + }, ] From 2258078a282087b29efccca1b06f8cb608cc1943 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 15 Aug 2024 03:32:02 +0530 Subject: [PATCH 08/20] feat(anta): Added the test case to verify Update error counters for BGP neighbors (#775) --- anta/custom_types.py | 1 + anta/tests/routing/bgp.py | 93 +++++- examples/tests.yaml | 7 + tests/units/anta_tests/routing/test_bgp.py | 319 +++++++++++++++++++++ 4 files changed, 419 insertions(+), 1 deletion(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index a0a0631d0..8a9070579 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -167,3 +167,4 @@ def validate_regex(value: str) -> str: Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)] Port = Annotated[int, Field(ge=1, le=65535)] RegexString = Annotated[str, AfterValidator(validate_regex)] +BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 7bd39ddcc..68225a6c9 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -14,7 +14,7 @@ from pydantic.v1.utils import deep_update from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Afi, MultiProtocolCaps, Safi, Vni +from anta.custom_types import Afi, BgpUpdateError, MultiProtocolCaps, Safi, Vni from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_item, get_value @@ -1226,3 +1226,94 @@ def test(self) -> None: self.result.is_success() else: self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}") + + +class VerifyBGPPeerUpdateErrors(AntaTest): + """Verifies BGP update error counters for the provided BGP IPv4 peer(s). + + By default, all update error counters will be checked for any non-zero values. + An optional list of specific update error counters can be provided for granular testing. + + Note: For "disabledAfiSafi" error counter field, checking that it's not "None" versus 0. + + Expected Results + ---------------- + * Success: The test will pass if the BGP peer's update error counter(s) are zero/None. + * Failure: The test will fail if the BGP peer's update error counter(s) are non-zero/not None/Not Found or + peer is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerUpdateErrors: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + update_error_filter: + - inUpdErrWithdraw + ``` + """ + + name = "VerifyBGPPeerUpdateErrors" + description = "Verifies the update error counters of a BGP IPv4 peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerUpdateErrors test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers""" + + class BgpPeer(BaseModel): + """Model for a BGP peer.""" + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + update_errors: list[BgpUpdateError] | None = None + """Optional list of update error counters to be verified. If not provided, test will verifies all the update error counters.""" + + 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] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerUpdateErrors.""" + failures: dict[Any, Any] = {} + + for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers): + peer = command.params.peer + vrf = command.params.vrf + update_error_counters = input_entry.update_errors + + # Verify BGP peer. + if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None: + failures[peer] = {vrf: "Not configured"} + continue + + # Getting the BGP peer's error counters output. + error_counters_output = peer_detail.get("peerInUpdateErrors", {}) + + # In case update error counters not provided, It will check all the update error counters. + if not update_error_counters: + update_error_counters = error_counters_output + + # verifying the error counters. + error_counters_not_ok = { + ("disabledAfiSafi" if error_counter == "disabledAfiSafi" else error_counter): value + for error_counter in update_error_counters + if (value := error_counters_output.get(error_counter, "Not Found")) != "None" and value != 0 + } + if error_counters_not_ok: + failures[peer] = {vrf: error_counters_not_ok} + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BGP peers are not configured or have non-zero update error counters:\n{failures}") diff --git a/examples/tests.yaml b/examples/tests.yaml index c479c8739..4386d08a9 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -567,6 +567,13 @@ anta.tests.routing: vrf: default hold_time: 180 keep_alive_time: 60 + - VerifyBGPPeerUpdateErrors: + bgp_peers: + - peer_address: 10.100.0.8 + vrf: default + update_errors: + - inUpdErrWithdraw + - inUpdErrIgnore ospf: - VerifyOSPFNeighborState: - VerifyOSPFNeighborCount: diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index e712e12a8..34f83ff66 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -19,6 +19,7 @@ VerifyBGPPeerMPCaps, VerifyBGPPeerRouteRefreshCap, VerifyBGPPeersHealth, + VerifyBGPPeerUpdateErrors, VerifyBGPSpecificPeers, VerifyBGPTimers, VerifyEVPNType2Route, @@ -3722,4 +3723,322 @@ ], }, }, + { + "name": "success", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-found", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + {"vrfs": {}}, + {"vrfs": {}}, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "failure-errors", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "ipv4Unicast", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': {'disabledAfiSafi': 'ipv4Unicast'}}, " + "'10.100.0.9': {'MGMT': {'inUpdErrWithdraw': 1}}}" + ], + }, + }, + { + "name": "failure-not-found", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": {}, + }, + { + "vrfs": {}, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "success-all-error-counters", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 0, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default"}, + {"peer_address": "10.100.0.9", "vrf": "MGMT"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-all-error-counters", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "ipv4Unicast", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 1, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + { + "peer_address": "10.100.0.9", + "vrf": "MGMT", + "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi", "inUpdErrDisableAfiSafi"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': {'inUpdErrWithdraw': 1, 'disabledAfiSafi': 'ipv4Unicast'}}, " + "'10.100.0.9': {'MGMT': {'inUpdErrWithdraw': 1, 'inUpdErrDisableAfiSafi': 1}}}" + ], + }, + }, + { + "name": "failure-all-not-found", + "test": VerifyBGPPeerUpdateErrors, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "peerInUpdateErrors": { + "inUpdErrIgnore": 0, + "inUpdErrDisableAfiSafi": 0, + "disabledAfiSafi": "ipv4Unicast", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "peerInUpdateErrors": { + "inUpdErrWithdraw": 1, + "inUpdErrIgnore": 0, + "disabledAfiSafi": "None", + "lastUpdErrTime": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi"]}, + { + "peer_address": "10.100.0.9", + "vrf": "MGMT", + "update_errors": ["inUpdErrWithdraw", "inUpdErrIgnore", "disabledAfiSafi", "inUpdErrDisableAfiSafi"], + }, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero update error counters:\n" + "{'10.100.0.8': {'default': {'inUpdErrWithdraw': 'Not Found', 'disabledAfiSafi': 'ipv4Unicast'}}, " + "'10.100.0.9': {'MGMT': {'inUpdErrWithdraw': 1, 'inUpdErrDisableAfiSafi': 'Not Found'}}}" + ], + }, + }, ] From c37c089e37bf90388a61d18475c2e089828be27c Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 15 Aug 2024 06:46:53 +0530 Subject: [PATCH 09/20] feat(anta): Added the test case to verify Inbound/outbound stats for BGP neighbors (#778) --- anta/custom_types.py | 30 ++ anta/tests/routing/bgp.py | 89 +++++- examples/tests.yaml | 11 + tests/units/anta_tests/routing/test_bgp.py | 316 +++++++++++++++++++++ 4 files changed, 445 insertions(+), 1 deletion(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index 8a9070579..56c213977 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -167,4 +167,34 @@ def validate_regex(value: str) -> str: Hostname = Annotated[str, Field(pattern=REGEXP_TYPE_HOSTNAME)] Port = Annotated[int, Field(ge=1, le=65535)] RegexString = Annotated[str, AfterValidator(validate_regex)] +BgpDropStats = Literal[ + "inDropAsloop", + "inDropClusterIdLoop", + "inDropMalformedMpbgp", + "inDropOrigId", + "inDropNhLocal", + "inDropNhAfV6", + "prefixDroppedMartianV4", + "prefixDroppedMaxRouteLimitViolatedV4", + "prefixDroppedMartianV6", + "prefixDroppedMaxRouteLimitViolatedV6", + "prefixLuDroppedV4", + "prefixLuDroppedMartianV4", + "prefixLuDroppedMaxRouteLimitViolatedV4", + "prefixLuDroppedV6", + "prefixLuDroppedMartianV6", + "prefixLuDroppedMaxRouteLimitViolatedV6", + "prefixEvpnDroppedUnsupportedRouteType", + "prefixBgpLsDroppedReceptionUnsupported", + "outDropV4LocalAddr", + "outDropV6LocalAddr", + "prefixVpnIpv4DroppedImportMatchFailure", + "prefixVpnIpv4DroppedMaxRouteLimitViolated", + "prefixVpnIpv6DroppedImportMatchFailure", + "prefixVpnIpv6DroppedMaxRouteLimitViolated", + "prefixEvpnDroppedImportMatchFailure", + "prefixEvpnDroppedMaxRouteLimitViolated", + "prefixRtMembershipDroppedLocalAsReject", + "prefixRtMembershipDroppedMaxRouteLimitViolated", +] BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index 68225a6c9..6a7002356 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -14,7 +14,7 @@ from pydantic.v1.utils import deep_update from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Afi, BgpUpdateError, MultiProtocolCaps, Safi, Vni +from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, Safi, Vni from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import get_item, get_value @@ -1228,6 +1228,93 @@ def test(self) -> None: self.result.is_failure(f"Following BGP peers are not configured or hold and keep-alive timers are not correct:\n{failures}") +class VerifyBGPPeerDropStats(AntaTest): + """Verifies BGP NLRI drop statistics for the provided BGP IPv4 peer(s). + + By default, all drop statistics counters will be checked for any non-zero values. + An optional list of specific drop statistics can be provided for granular testing. + + Expected Results + ---------------- + * Success: The test will pass if the BGP peer's drop statistic(s) are zero. + * Failure: The test will fail if the BGP peer's drop statistic(s) are non-zero/Not Found or peer is not configured. + + Examples + -------- + ```yaml + anta.tests.routing: + bgp: + - VerifyBGPPeerDropStats: + bgp_peers: + - peer_address: 172.30.11.1 + vrf: default + drop_stats: + - inDropAsloop + - prefixEvpnDroppedUnsupportedRouteType + ``` + """ + + name = "VerifyBGPPeerDropStats" + description = "Verifies the NLRI drop statistics of a BGP IPv4 peer(s)." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerDropStats test.""" + + bgp_peers: list[BgpPeer] + """List of BGP peers""" + + class BgpPeer(BaseModel): + """Model for a BGP peer.""" + + peer_address: IPv4Address + """IPv4 address of a BGP peer.""" + vrf: str = "default" + """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + drop_stats: list[BgpDropStats] | None = None + """Optional list of drop statistics to be verified. If not provided, test will verifies all the drop statistics.""" + + 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] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBGPPeerDropStats.""" + failures: dict[Any, Any] = {} + + for command, input_entry in zip(self.instance_commands, self.inputs.bgp_peers): + peer = command.params.peer + vrf = command.params.vrf + drop_statistics = input_entry.drop_stats + + # Verify BGP peer + if not (peer_list := get_value(command.json_output, f"vrfs.{vrf}.peerList")) or (peer_detail := get_item(peer_list, "peerAddress", peer)) is None: + failures[peer] = {vrf: "Not configured"} + continue + + # Verify BGP peer's drop stats + drop_stats_output = peer_detail.get("dropStats", {}) + + # In case drop stats not provided, It will check all drop statistics + if not drop_statistics: + drop_statistics = drop_stats_output + + # Verify BGP peer's drop stats + drop_stats_not_ok = { + drop_stat: drop_stats_output.get(drop_stat, "Not Found") for drop_stat in drop_statistics if drop_stats_output.get(drop_stat, "Not Found") + } + if any(drop_stats_not_ok): + failures[peer] = {vrf: drop_stats_not_ok} + + # Check if any failures + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n{failures}") + + class VerifyBGPPeerUpdateErrors(AntaTest): """Verifies BGP update error counters for the provided BGP IPv4 peer(s). diff --git a/examples/tests.yaml b/examples/tests.yaml index 4386d08a9..c4248cf75 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -567,6 +567,17 @@ anta.tests.routing: vrf: default hold_time: 180 keep_alive_time: 60 + - VerifyBGPPeerDropStats: + bgp_peers: + - peer_address: 10.101.0.4 + vrf: default + drop_stats: + - inDropAsloop + - inDropClusterIdLoop + - inDropMalformedMpbgp + - inDropOrigId + - inDropNhLocal + - inDropNhAfV6 - VerifyBGPPeerUpdateErrors: bgp_peers: - peer_address: 10.100.0.8 diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 34f83ff66..47db8e60b 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -15,6 +15,7 @@ VerifyBGPExchangedRoutes, VerifyBGPPeerASNCap, VerifyBGPPeerCount, + VerifyBGPPeerDropStats, VerifyBGPPeerMD5Auth, VerifyBGPPeerMPCaps, VerifyBGPPeerRouteRefreshCap, @@ -3723,6 +3724,321 @@ ], }, }, + { + "name": "success", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "10.100.0.8", + "vrf": "default", + "drop_stats": ["prefixDroppedMartianV4", "prefixDroppedMaxRouteLimitViolatedV4", "prefixDroppedMartianV6"], + }, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "drop_stats": ["inDropClusterIdLoop", "inDropOrigId", "inDropNhLocal"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-found", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + {"vrfs": {}}, + {"vrfs": {}}, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "10.100.0.8", + "vrf": "default", + "drop_stats": ["prefixDroppedMartianV4", "prefixDroppedMaxRouteLimitViolatedV4", "prefixDroppedMartianV6"], + }, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "drop_stats": ["inDropClusterIdLoop", "inDropOrigId", "inDropNhLocal"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': 'Not configured'}, '10.100.0.9': {'MGMT': 'Not configured'}}" + ], + }, + }, + { + "name": "failure", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 1, + "prefixDroppedMaxRouteLimitViolatedV4": 1, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + { + "peer_address": "10.100.0.8", + "vrf": "default", + "drop_stats": ["prefixDroppedMartianV4", "prefixDroppedMaxRouteLimitViolatedV4", "prefixDroppedMartianV6"], + }, + {"peer_address": "10.100.0.9", "vrf": "MGMT", "drop_stats": ["inDropClusterIdLoop", "inDropOrigId", "inDropNhLocal"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': {'prefixDroppedMartianV4': 1, 'prefixDroppedMaxRouteLimitViolatedV4': 1}}, " + "'10.100.0.9': {'MGMT': {'inDropOrigId': 1, 'inDropNhLocal': 1}}}" + ], + }, + }, + { + "name": "success-all-drop-stats", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 0, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 0, + "inDropNhLocal": 0, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default"}, + {"peer_address": "10.100.0.9", "vrf": "MGMT"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-all-drop-stats", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 3, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 1, + "prefixDroppedMaxRouteLimitViolatedV4": 1, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + { + "vrfs": { + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.9", + "dropStats": { + "inDropAsloop": 2, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMartianV4": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 0, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default"}, + {"peer_address": "10.100.0.9", "vrf": "MGMT"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': {'inDropAsloop': 3, 'inDropOrigId': 1, 'inDropNhLocal': 1, " + "'prefixDroppedMartianV4': 1, 'prefixDroppedMaxRouteLimitViolatedV4': 1}}, " + "'10.100.0.9': {'MGMT': {'inDropAsloop': 2, 'inDropOrigId': 1, 'inDropNhLocal': 1}}}" + ], + }, + }, + { + "name": "failure-drop-stat-not-found", + "test": VerifyBGPPeerDropStats, + "eos_data": [ + { + "vrfs": { + "default": { + "peerList": [ + { + "peerAddress": "10.100.0.8", + "dropStats": { + "inDropAsloop": 3, + "inDropClusterIdLoop": 0, + "inDropMalformedMpbgp": 0, + "inDropOrigId": 1, + "inDropNhLocal": 1, + "inDropNhAfV6": 0, + "prefixDroppedMaxRouteLimitViolatedV4": 1, + "prefixDroppedMartianV6": 0, + }, + } + ] + }, + }, + }, + ], + "inputs": { + "bgp_peers": [ + {"peer_address": "10.100.0.8", "vrf": "default", "drop_stats": ["inDropAsloop", "inDropOrigId", "inDropNhLocal", "prefixDroppedMartianV4"]} + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BGP peers are not configured or have non-zero NLRI drop statistics counters:\n" + "{'10.100.0.8': {'default': {'inDropAsloop': 3, 'inDropOrigId': 1, 'inDropNhLocal': 1, 'prefixDroppedMartianV4': 'Not Found'}}}" + ], + }, + }, { "name": "success", "test": VerifyBGPPeerUpdateErrors, From e9925d351c1515a56e762b51129534aae6c2f7c7 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Mon, 19 Aug 2024 13:29:53 -0400 Subject: [PATCH 10/20] fix(anta): Add upper bound on Griffe requirement for v1 (#794) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6cafa5b7f..ecfcba289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ dev = [ ] doc = [ "fontawesome_markdown", - "griffe", + "griffe >=0.46,<1.0.0", "mike==2.1.3", "mkdocs-autorefs>=0.4.1", "mkdocs-bootswatch>=1.1", From 08e945b5bf1cc110e4200cf45a391c7b284da6ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Aug 2024 13:40:37 -0400 Subject: [PATCH 11/20] chore: update ruff requirement from <0.6.0,>=0.5.4 to >=0.5.4,<0.7.0 (#790) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Carl Baillargeon --- .pre-commit-config.yaml | 2 +- asynceapi/aio_portcheck.py | 2 +- pyproject.toml | 2 +- tests/lib/fixture.py | 16 ++++++++-------- tests/units/cli/exec/test_utils.py | 2 +- tests/units/test_device.py | 8 ++++---- tests/units/test_runner.py | 12 ++++++------ 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f632b99f..75d4388a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.7 + rev: v0.6.1 hooks: - id: ruff name: Run Ruff linter diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py index 79f4562fa..fd8e7aee2 100644 --- a/asynceapi/aio_portcheck.py +++ b/asynceapi/aio_portcheck.py @@ -33,7 +33,7 @@ # ----------------------------------------------------------------------------- -async def port_check_url(url: URL, timeout: int = 5) -> bool: +async def port_check_url(url: URL, timeout: int = 5) -> bool: # noqa: ASYNC109 """ Open the port designated by the URL given the timeout in seconds. diff --git a/pyproject.toml b/pyproject.toml index ecfcba289..e64ee80df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dev = [ "pytest-html>=3.2.0", "pytest-metadata>=3.0.0", "pytest>=7.4.0", - "ruff>=0.5.4,<0.6.0", + "ruff>=0.5.4,<0.7.0", "tox>=4.10.0,<5.0.0", "types-PyYAML", "types-pyOpenSSL", diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 17943edc3..b0205b8bb 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -58,7 +58,7 @@ } -@pytest.fixture() +@pytest.fixture def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: """Return an AntaDevice instance with mocked abstract method.""" @@ -78,7 +78,7 @@ def _collect(command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: yield dev -@pytest.fixture() +@pytest.fixture def test_inventory() -> AntaInventory: """Return the test_inventory.""" env = default_anta_env() @@ -93,7 +93,7 @@ def test_inventory() -> AntaInventory: # tests.unit.test_device.py fixture -@pytest.fixture() +@pytest.fixture def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: """Return an AsyncEOSDevice instance.""" kwargs = { @@ -110,7 +110,7 @@ def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: # tests.units.result_manager fixtures -@pytest.fixture() +@pytest.fixture def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: """Return a anta.result_manager.models.TestResult object.""" # pylint: disable=redefined-outer-name @@ -128,7 +128,7 @@ def _create(index: int = 0) -> TestResult: return _create -@pytest.fixture() +@pytest.fixture def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]: """Return a list[TestResult] with 'size' TestResult instantiated using the test_result_factory fixture.""" # pylint: disable=redefined-outer-name @@ -140,7 +140,7 @@ def _factory(size: int = 0) -> list[TestResult]: return _factory -@pytest.fixture() +@pytest.fixture def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]: """Return a ResultManager factory that takes as input a number of tests.""" # pylint: disable=redefined-outer-name @@ -155,7 +155,7 @@ def _factory(number: int = 0) -> ResultManager: # tests.units.cli fixtures -@pytest.fixture() +@pytest.fixture def temp_env(tmp_path: Path) -> dict[str, str | None]: """Fixture that create a temporary ANTA inventory. @@ -169,7 +169,7 @@ def temp_env(tmp_path: Path) -> dict[str, str | None]: return env -@pytest.fixture() +@pytest.fixture # Disabling C901 - too complex as we like our runner like this def click_runner(capsys: pytest.CaptureFixture[str]) -> Iterator[CliRunner]: # noqa: C901 """Return a click.CliRunner for cli testing.""" diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py index ad1a78ab1..f4c0cc5fd 100644 --- a/tests/units/cli/exec/test_utils.py +++ b/tests/units/cli/exec/test_utils.py @@ -23,7 +23,7 @@ # TODO: complete test cases -@pytest.mark.asyncio() +@pytest.mark.asyncio @pytest.mark.parametrize( ("inventory_state", "per_device_command_output", "tags"), [ diff --git a/tests/units/test_device.py b/tests/units/test_device.py index e8a0c5f86..d3c50cc8e 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -613,7 +613,7 @@ class TestAntaDevice: """Test for anta.device.AntaDevice Abstract class.""" - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("device", "command_data", "expected_data"), ((d["device"], d["command"], d["expected"]) for d in COLLECT_DATA), @@ -693,7 +693,7 @@ def test__eq(self, data: dict[str, Any]) -> None: else: assert device1 != device2 - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "patch_kwargs", "expected"), ((d["device"], d["patch_kwargs"], d["expected"]) for d in REFRESH_DATA), @@ -712,7 +712,7 @@ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[di assert async_device.established == expected["established"] assert async_device.hw_model == expected["hw_model"] - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "command", "expected"), ((d["device"], d["command"], d["expected"]) for d in ASYNCEAPI_COLLECT_DATA), @@ -745,7 +745,7 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] - @pytest.mark.asyncio() + @pytest.mark.asyncio @pytest.mark.parametrize( ("async_device", "copy"), ((d["device"], d["copy"]) for d in ASYNCEAPI_COPY_DATA), diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index 955149d09..53d0bf758 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -24,7 +24,7 @@ FAKE_CATALOG: AntaCatalog = AntaCatalog.from_list([(FakeTest, None)]) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_empty_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test that when the list of tests is empty, a log is raised. @@ -40,7 +40,7 @@ async def test_runner_empty_tests(caplog: pytest.LogCaptureFixture, test_invento assert "The list of tests is empty, exiting" in caplog.records[0].message -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: """Test that when the Inventory is empty, a log is raised. @@ -55,7 +55,7 @@ async def test_runner_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: assert "The inventory is empty, exiting" in caplog.records[1].message -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_no_selected_device(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test that when the list of established device. @@ -140,7 +140,7 @@ def side_effect_setrlimit(resource_id: int, limits: tuple[int, int]) -> None: setrlimit_mock.assert_called_once_with(resource.RLIMIT_NOFILE, (16384, 1048576)) -@pytest.mark.asyncio() +@pytest.mark.asyncio @pytest.mark.parametrize( ("tags", "expected_tests_count", "expected_devices_count"), [ @@ -173,7 +173,7 @@ async def test_prepare_tests( assert sum(len(tests) for tests in selected_tests.values()) == expected_tests_count -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_prepare_tests_with_specific_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test the runner prepare_tests function with specific tests.""" logger.setup_logging(logger.Log.INFO) @@ -187,7 +187,7 @@ async def test_prepare_tests_with_specific_tests(caplog: pytest.LogCaptureFixtur assert sum(len(tests) for tests in selected_tests.values()) == 5 -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_runner_dry_run(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: """Test that when dry_run is True, no tests are run. From b9f95aebae28e9a182f0ae8b3aba8f9ee257816a Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Wed, 21 Aug 2024 23:29:57 +0530 Subject: [PATCH 12/20] feat(anta): Added test case to verify registered protocol for IPv4 BFD peers (#773) --- anta/custom_types.py | 1 + anta/tests/bfd.py | 82 +++++++++++++++++- examples/tests.yaml | 7 ++ tests/units/anta_tests/test_bfd.py | 131 ++++++++++++++++++++++++++++- 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/anta/custom_types.py b/anta/custom_types.py index 56c213977..153fd7011 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -198,3 +198,4 @@ def validate_regex(value: str) -> str: "prefixRtMembershipDroppedMaxRouteLimitViolated", ] BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"] +BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"] diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index f19e9cc92..0b171a6d2 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field -from anta.custom_types import BfdInterval, BfdMultiplier +from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -45,7 +45,7 @@ class VerifyBFDSpecificPeers(AntaTest): name = "VerifyBFDSpecificPeers" description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF." categories: ClassVar[list[str]] = ["bfd"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyBFDSpecificPeers test.""" @@ -126,7 +126,7 @@ class VerifyBFDPeersIntervals(AntaTest): name = "VerifyBFDPeersIntervals" description = "Verifies the timers of the IPv4 BFD peers in the specified VRF." categories: ClassVar[list[str]] = ["bfd"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=4)] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyBFDPeersIntervals test.""" @@ -285,3 +285,79 @@ def test(self) -> None: if up_failures: up_failures_str = "\n".join(up_failures) self.result.is_failure(f"\nFollowing BFD peers were down:\n{up_failures_str}") + + +class VerifyBFDPeersRegProtocols(AntaTest): + """Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered. + + Expected Results + ---------------- + * Success: The test will pass if IPv4 BFD peers are registered with the specified protocol(s). + * Failure: The test will fail if IPv4 BFD peers are not found or the specified protocol(s) are not registered for the BFD peer(s). + + Examples + -------- + ```yaml + anta.tests.bfd: + - VerifyBFDPeersRegProtocols: + bfd_peers: + - peer_address: 192.0.255.7 + vrf: default + protocols: + - bgp + ``` + """ + + name = "VerifyBFDPeersRegProtocols" + description = "Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered." + categories: ClassVar[list[str]] = ["bfd"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyBFDPeersRegProtocols test.""" + + bfd_peers: list[BFDPeer] + """List of IPv4 BFD peers.""" + + class BFDPeer(BaseModel): + """Model for an IPv4 BFD peer.""" + + peer_address: IPv4Address + """IPv4 address of a BFD peer.""" + vrf: str = "default" + """Optional VRF for BFD peer. If not provided, it defaults to `default`.""" + protocols: list[BfdProtocol] + """List of protocols to be verified.""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyBFDPeersRegProtocols.""" + # Initialize failure messages + failures: dict[Any, Any] = {} + + # Iterating over BFD peers, extract the parameters and command output + for bfd_peer in self.inputs.bfd_peers: + peer = str(bfd_peer.peer_address) + vrf = bfd_peer.vrf + protocols = bfd_peer.protocols + bfd_output = get_value( + self.instance_commands[0].json_output, + f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", + separator="..", + ) + + # Check if BFD peer configured + if not bfd_output: + failures[peer] = {vrf: "Not Configured"} + continue + + # Check registered protocols + difference = set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps")) + + if difference: + failures[peer] = {vrf: sorted(difference)} + + if not failures: + self.result.is_success() + else: + self.result.is_failure(f"The following BFD peers are not configured or have non-registered protocol(s):\n{failures}") diff --git a/examples/tests.yaml b/examples/tests.yaml index c4248cf75..58161972f 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -83,6 +83,13 @@ anta.tests.bfd: multiplier: 3 - VerifyBFDPeersHealth: down_threshold: 2 + - VerifyBFDPeersRegProtocols: + bfd_peers: + - peer_address: 192.0.255.8 + vrf: default + protocols: + - bgp + - isis anta.tests.configuration: - VerifyZeroTouch: diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index 54dc7a05e..b3ab5609a 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -10,7 +10,7 @@ # pylint: disable=C0413 # because of the patch above -from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDSpecificPeers +from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDPeersRegProtocols, VerifyBFDSpecificPeers from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 DATA: list[dict[str, Any]] = [ @@ -519,4 +519,133 @@ ], }, }, + { + "name": "success", + "test": VerifyBFDPeersRegProtocols, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + "peerStatsDetail": { + "role": "active", + "apps": ["ospf"], + }, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 108328132, + "peerStatsDetail": { + "role": "active", + "apps": ["bgp"], + }, + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["ospf"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["bgp"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyBFDPeersRegProtocols, + "eos_data": [ + { + "vrfs": { + "default": { + "ipv4Neighbors": { + "192.0.255.7": { + "peerStats": { + "": { + "status": "up", + "peerStatsDetail": { + "role": "active", + "apps": ["ospf"], + }, + } + } + } + } + }, + "MGMT": { + "ipv4Neighbors": { + "192.0.255.70": { + "peerStats": { + "": { + "status": "up", + "remoteDisc": 0, + "peerStatsDetail": { + "role": "active", + "apps": ["bgp"], + }, + } + } + } + } + }, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["isis"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["isis"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BFD peers are not configured or have non-registered protocol(s):\n" + "{'192.0.255.7': {'default': ['isis']}, " + "'192.0.255.70': {'MGMT': ['isis']}}" + ], + }, + }, + { + "name": "failure-not-found", + "test": VerifyBFDPeersRegProtocols, + "eos_data": [ + { + "vrfs": { + "default": {}, + "MGMT": {}, + } + } + ], + "inputs": { + "bfd_peers": [ + {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["isis"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["isis"]}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "The following BFD peers are not configured or have non-registered protocol(s):\n" + "{'192.0.255.7': {'default': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}" + ], + }, + }, ] From 61e206efb5636fc6d7498bd50a3a0aacceb45ee3 Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 22 Aug 2024 00:09:06 +0530 Subject: [PATCH 13/20] feat(anta): Added the test case to verify the Entropy source security (#780) --- anta/tests/security.py | 34 +++++++++++++++++++++++++ examples/tests.yaml | 1 + tests/units/anta_tests/test_security.py | 15 +++++++++++ 3 files changed, 50 insertions(+) diff --git a/anta/tests/security.py b/anta/tests/security.py index 4eb4d6415..ae5b9bebd 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -820,3 +820,37 @@ def test(self) -> None: self.result.is_failure( f"IPv4 security connection `source:{source_input} destination:{destination_input} vrf:{vrf}` for peer `{peer}` is not found." ) + + +class VerifyHardwareEntropy(AntaTest): + """ + Verifies hardware entropy generation is enabled on device. + + Expected Results + ---------------- + * Success: The test will pass if hardware entropy generation is enabled. + * Failure: The test will fail if hardware entropy generation is not enabled. + + Examples + -------- + ```yaml + anta.tests.security: + - VerifyHardwareEntropy: + ``` + """ + + name = "VerifyHardwareEntropy" + description = "Verifies hardware entropy generation is enabled on device." + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management security")] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyHardwareEntropy.""" + command_output = self.instance_commands[0].json_output + + # Check if hardware entropy generation is enabled. + if not command_output.get("hardwareEntropyEnabled"): + self.result.is_failure("Hardware entropy generation is disabled.") + else: + self.result.is_success() diff --git a/examples/tests.yaml b/examples/tests.yaml index 58161972f..c5f87fae7 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -354,6 +354,7 @@ anta.tests.security: destination_address: 100.64.2.2 - source_address: 172.18.3.2 destination_address: 172.18.2.2 + - VerifyHardwareEntropy: anta.tests.services: - VerifyHostname: diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py index 3a732bdaa..eabc40bd8 100644 --- a/tests/units/anta_tests/test_security.py +++ b/tests/units/anta_tests/test_security.py @@ -15,6 +15,7 @@ VerifyAPISSLCertificate, VerifyBannerLogin, VerifyBannerMotd, + VerifyHardwareEntropy, VerifyIPSecConnHealth, VerifyIPv4ACL, VerifySpecificIPSecConn, @@ -1213,4 +1214,18 @@ ], }, }, + { + "name": "success", + "test": VerifyHardwareEntropy, + "eos_data": [{"cpuModel": "2.20GHz", "cryptoModule": "Crypto Module v3.0", "hardwareEntropyEnabled": True, "blockedNetworkProtocols": []}], + "inputs": {}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyHardwareEntropy, + "eos_data": [{"cpuModel": "2.20GHz", "cryptoModule": "Crypto Module v3.0", "hardwareEntropyEnabled": False, "blockedNetworkProtocols": []}], + "inputs": {}, + "expected": {"result": "failure", "messages": ["Hardware entropy generation is disabled."]}, + }, ] From 9e53cae88e03e49406210108928714ba56f90f5b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:15:11 +0200 Subject: [PATCH 14/20] ci: pre-commit autoupdate (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.1 → v0.6.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.1...v0.6.2) - [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75d4388a1..ceef2b6c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.1 + rev: v0.6.2 hooks: - id: ruff name: Run Ruff linter @@ -80,7 +80,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy name: Check typing with mypy From 484275d95d45e4c60b1520c94297dc448b403263 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Tue, 27 Aug 2024 20:01:36 -0400 Subject: [PATCH 15/20] feat(anta): Added merge_catalogs function (#802) --- anta/catalog.py | 25 ++++++++++++++++++++++++- docs/usage-inventory-catalog.md | 29 ++++++++++++++++++----------- tests/units/test_catalog.py | 19 +++++++++++++++++-- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/anta/catalog.py b/anta/catalog.py index 30bd34066..7ed4bc718 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -10,9 +10,11 @@ import math from collections import defaultdict from inspect import isclass +from itertools import chain from json import load as json_load from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Optional, Union +from warnings import warn from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_serializer, model_validator from pydantic.types import ImportString @@ -386,6 +388,21 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: raise return AntaCatalog(tests) + @classmethod + def merge_catalogs(cls, catalogs: list[AntaCatalog]) -> AntaCatalog: + """Merge multiple AntaCatalog instances. + + Parameters + ---------- + catalogs: A list of AntaCatalog instances to merge. + + Returns + ------- + A new AntaCatalog instance containing the tests of all the input catalogs. + """ + combined_tests = list(chain(*(catalog.tests for catalog in catalogs))) + return cls(tests=combined_tests) + def merge(self, catalog: AntaCatalog) -> AntaCatalog: """Merge two AntaCatalog instances. @@ -397,7 +414,13 @@ def merge(self, catalog: AntaCatalog) -> AntaCatalog: ------- A new AntaCatalog instance containing the tests of the two instances. """ - return AntaCatalog(tests=self.tests + catalog.tests) + # TODO: Use a decorator to deprecate this method instead. See https://github.com/aristanetworks/anta/issues/754 + warn( + message="AntaCatalog.merge() is deprecated and will be removed in ANTA v2.0. Use AntaCatalog.merge_catalogs() instead.", + category=DeprecationWarning, + stacklevel=2, + ) + return self.merge_catalogs([self, catalog]) def dump(self) -> AntaCatalogFile: """Return an AntaCatalogFile instance from this AntaCatalog instance. diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index fd6aec320..5ae4cc923 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -309,7 +309,7 @@ Once you run `anta nrfu table`, you will see following output: ### Example script to merge catalogs -The following script reads all the files in `intended/test_catalogs/` with names `-catalog.yml` and merge them together inside one big catalog `anta-catalog.yml`. +The following script reads all the files in `intended/test_catalogs/` with names `-catalog.yml` and merge them together inside one big catalog `anta-catalog.yml` using the new `AntaCatalog.merge_catalogs()` class method. ```python #!/usr/bin/env python @@ -319,19 +319,26 @@ from pathlib import Path from anta.models import AntaTest -CATALOG_SUFFIX = '-catalog.yml' -CATALOG_DIR = 'intended/test_catalogs/' +CATALOG_SUFFIX = "-catalog.yml" +CATALOG_DIR = "intended/test_catalogs/" if __name__ == "__main__": - catalog = AntaCatalog() - for file in Path(CATALOG_DIR).glob('*'+CATALOG_SUFFIX): - c = AntaCatalog.parse(file) + catalogs = [] + for file in Path(CATALOG_DIR).glob("*" + CATALOG_SUFFIX): device = str(file).removesuffix(CATALOG_SUFFIX).removeprefix(CATALOG_DIR) - print(f"Merging test catalog for device {device}") - # Apply filters to all tests for this device - for test in c.tests: - test.inputs.filters = AntaTest.Input.Filters(tags=[device]) - catalog = catalog.merge(c) + print(f"Loading test catalog for device {device}") + catalog = AntaCatalog.parse(file) + # Add the device name as a tag to all tests in the catalog + for test in catalog.tests: + test.inputs.filters = AntaTest.Input.Filters(tags={device}) + catalogs.append(catalog) + + # Merge all catalogs + merged_catalog = AntaCatalog.merge_catalogs(catalogs) + + # Save the merged catalog to a file with open(Path('anta-catalog.yml'), "w") as f: f.write(catalog.dump().yaml()) ``` +!!! warning + The `AntaCatalog.merge()` method is deprecated and will be removed in ANTA v2.0. Please use the `AntaCatalog.merge_catalogs()` class method instead. diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index 76358dd4a..13046f294 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -345,6 +345,17 @@ def test_get_tests_by_tags(self) -> None: tests = catalog.get_tests_by_tags(tags={"leaf", "spine"}, strict=True) assert len(tests) == 1 + def test_merge_catalogs(self) -> None: + """Test the merge_catalogs function.""" + # Load catalogs of different sizes + small_catalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml") + medium_catalog = AntaCatalog.parse(DATA_DIR / "test_catalog_medium.yml") + tagged_catalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml") + + # Merge the catalogs and check the number of tests + final_catalog = AntaCatalog.merge_catalogs([small_catalog, medium_catalog, tagged_catalog]) + assert len(final_catalog.tests) == len(small_catalog.tests) + len(medium_catalog.tests) + len(tagged_catalog.tests) + def test_merge(self) -> None: """Test AntaCatalog.merge().""" catalog1: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog.yml") @@ -354,11 +365,15 @@ def test_merge(self) -> None: catalog3: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_medium.yml") assert len(catalog3.tests) == 228 - assert len(catalog1.merge(catalog2).tests) == 2 + with pytest.deprecated_call(): + merged_catalog = catalog1.merge(catalog2) + assert len(merged_catalog.tests) == 2 assert len(catalog1.tests) == 1 assert len(catalog2.tests) == 1 - assert len(catalog2.merge(catalog3).tests) == 229 + with pytest.deprecated_call(): + merged_catalog = catalog2.merge(catalog3) + assert len(merged_catalog.tests) == 229 assert len(catalog2.tests) == 1 assert len(catalog3.tests) == 228 From 90299d1ba32fc68b188541593af26aae34826a8a Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Tue, 27 Aug 2024 20:01:55 -0400 Subject: [PATCH 16/20] fix(anta): Remove JSON output when saving to a file (#800) --- anta/cli/nrfu/commands.py | 2 +- anta/cli/nrfu/utils.py | 21 ++++++++++----- docs/cli/nrfu.md | 6 ++--- tests/units/cli/nrfu/test_commands.py | 39 ++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index cd750cb85..6043dbef9 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -42,7 +42,7 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), show_envvar=True, required=False, - help="Path to save report as a file", + help="Path to save report as a JSON file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: """ANTA command to check network state with JSON result.""" diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 284c9b709..cfc2e1ed1 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -94,14 +94,21 @@ def print_table(ctx: click.Context, group_by: Literal["device", "test"] | None = def print_json(ctx: click.Context, output: pathlib.Path | None = None) -> None: - """Print result in a json format.""" + """Print results as JSON. If output is provided, save to file instead.""" results = _get_result_manager(ctx) - console.print() - console.print(Panel("JSON results", style="cyan")) - rich.print_json(results.json) - if output is not None: - with output.open(mode="w", encoding="utf-8") as fout: - fout.write(results.json) + + if output is None: + console.print() + console.print(Panel("JSON results", style="cyan")) + rich.print_json(results.json) + else: + try: + with output.open(mode="w", encoding="utf-8") as file: + file.write(results.json) + console.print(f"JSON results saved to {output} ✅", style="cyan") + except OSError: + console.print(f"Failed to save JSON results to {output} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) def print_text(ctx: click.Context) -> None: diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index afed25949..2f4e7eedc 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -120,7 +120,7 @@ anta nrfu --test VerifyZeroTouch table ## Performing NRFU with JSON rendering -The JSON rendering command in NRFU testing is useful in generating a JSON output that can subsequently be passed on to another tool for reporting purposes. +The JSON rendering command in NRFU testing will generate an output of all test results in JSON format. ### Command overview @@ -131,12 +131,12 @@ Usage: anta nrfu json [OPTIONS] ANTA command to check network state with JSON result. Options: - -o, --output FILE Path to save report as a file [env var: + -o, --output FILE Path to save report as a JSON file [env var: ANTA_NRFU_JSON_OUTPUT] --help Show this message and exit. ``` -The `--output` option allows you to save the JSON report as a file. +The `--output` option allows you to save the JSON report as a file. If specified, no output will be displayed in the terminal. This is useful for further processing or integration with other tools. ### Example diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 8ad7745f4..803c8f803 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -8,7 +8,7 @@ import json import re from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from unittest.mock import patch from anta.cli import anta @@ -90,6 +90,43 @@ def test_anta_nrfu_json(click_runner: CliRunner) -> None: assert res["result"] == "success" +def test_anta_nrfu_json_output(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu json with output file.""" + json_output = tmp_path / "test.json" + result = click_runner.invoke(anta, ["nrfu", "json", "--output", str(json_output)]) + + # Making sure the output is not printed to stdout + match = re.search(r"\[\n {2}{[\s\S]+ {2}}\n\]", result.output) + assert match is None + + assert result.exit_code == ExitCode.OK + assert "JSON results saved to" in result.output + assert json_output.exists() + + +def test_anta_nrfu_json_output_failure(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu json with output file.""" + json_output = tmp_path / "test.json" + + original_open = Path.open + + def mock_path_open(*args: Any, **kwargs: Any) -> Path: # noqa: ANN401 + """Mock Path.open only for the json_output file of this test.""" + if args[0] == json_output: + msg = "Simulated OSError" + raise OSError(msg) + + # If not the json_output file, call the original Path.open + return original_open(*args, **kwargs) + + with patch("pathlib.Path.open", mock_path_open): + result = click_runner.invoke(anta, ["nrfu", "json", "--output", str(json_output)]) + + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Failed to save JSON results to" in result.output + assert not json_output.exists() + + def test_anta_nrfu_template(click_runner: CliRunner) -> None: """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")]) From bb76a5ab23dddd7e9bb591522b7996c295d8b2b8 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 29 Aug 2024 03:43:38 -0400 Subject: [PATCH 17/20] doc: Fix merge_catalogs script (#804) fix(doc): Fix merge_catalogs script --- docs/usage-inventory-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage-inventory-catalog.md b/docs/usage-inventory-catalog.md index 5ae4cc923..d8a032f26 100644 --- a/docs/usage-inventory-catalog.md +++ b/docs/usage-inventory-catalog.md @@ -338,7 +338,7 @@ if __name__ == "__main__": # Save the merged catalog to a file with open(Path('anta-catalog.yml'), "w") as f: - f.write(catalog.dump().yaml()) + f.write(merged_catalog.dump().yaml()) ``` !!! warning The `AntaCatalog.merge()` method is deprecated and will be removed in ANTA v2.0. Please use the `AntaCatalog.merge_catalogs()` class method instead. From 7bb456000a3dadfedbe1d1df400edd8315867708 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 29 Aug 2024 08:14:06 -0400 Subject: [PATCH 18/20] feat(anta): Add Markdown report option to ANTA (#740) --- .pre-commit-config.yaml | 2 +- anta/cli/nrfu/__init__.py | 1 + anta/cli/nrfu/commands.py | 24 +- anta/cli/nrfu/utils.py | 17 + anta/constants.py | 19 ++ anta/reporter/__init__.py | 36 +- anta/reporter/md_reporter.py | 287 ++++++++++++++++ anta/result_manager/__init__.py | 154 ++++++++- anta/result_manager/models.py | 41 +++ docs/cli/nrfu.md | 25 +- docs/imgs/anta-nrfu-md-report-output.png | Bin 0 -> 165566 bytes docs/snippets/anta_nrfu_help.txt | 7 +- tests/data/test_md_report.md | 79 +++++ tests/data/test_md_report_results.json | 378 +++++++++++++++++++++ tests/lib/fixture.py | 32 +- tests/units/cli/nrfu/test__init__.py | 6 + tests/units/cli/nrfu/test_commands.py | 20 ++ tests/units/reporter/test_md_reporter.py | 54 +++ tests/units/result_manager/test__init__.py | 107 ++++++ 19 files changed, 1237 insertions(+), 52 deletions(-) create mode 100644 anta/constants.py create mode 100644 anta/reporter/md_reporter.py create mode 100644 docs/imgs/anta-nrfu-md-report-output.png create mode 100644 tests/data/test_md_report.md create mode 100644 tests/data/test_md_report_results.json create mode 100644 tests/units/reporter/test_md_reporter.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ceef2b6c5..f716fb97c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - name: Check and insert license on Markdown files id: insert-license files: .*\.md$ - # exclude: + exclude: ^tests/data/.*\.md$ args: - --license-filepath - .github/license-short.txt diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index a85277102..6263e845a 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -147,3 +147,4 @@ def nrfu( nrfu.add_command(commands.json) nrfu.add_command(commands.text) nrfu.add_command(commands.tpl_report) +nrfu.add_command(commands.md_report) diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index 6043dbef9..a5492680b 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -13,7 +13,7 @@ from anta.cli.utils import exit_with_code -from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_to_csv +from .utils import print_jinja, print_json, print_table, print_text, run_tests, save_markdown_report, save_to_csv logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ required=False, ) def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> None: - """ANTA command to check network states with table result.""" + """ANTA command to check network state with table results.""" run_tests(ctx) print_table(ctx, group_by=group_by) exit_with_code(ctx) @@ -45,7 +45,7 @@ def table(ctx: click.Context, group_by: Literal["device", "test"] | None) -> Non help="Path to save report as a JSON file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: - """ANTA command to check network state with JSON result.""" + """ANTA command to check network state with JSON results.""" run_tests(ctx) print_json(ctx, output=output) exit_with_code(ctx) @@ -54,7 +54,7 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: @click.command() @click.pass_context def text(ctx: click.Context) -> None: - """ANTA command to check network states with text result.""" + """ANTA command to check network state with text results.""" run_tests(ctx) print_text(ctx) exit_with_code(ctx) @@ -105,3 +105,19 @@ def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path run_tests(ctx) print_jinja(results=ctx.obj["result_manager"], template=template, output=output) exit_with_code(ctx) + + +@click.command() +@click.pass_context +@click.option( + "--md-output", + type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True, path_type=pathlib.Path), + show_envvar=True, + required=True, + help="Path to save the report as a Markdown file", +) +def md_report(ctx: click.Context, md_output: pathlib.Path) -> None: + """ANTA command to check network state with Markdown report.""" + run_tests(ctx) + save_markdown_report(ctx, md_output=md_output) + exit_with_code(ctx) diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index cfc2e1ed1..748578dec 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -19,6 +19,7 @@ from anta.models import AntaTest from anta.reporter import ReportJinja, ReportTable from anta.reporter.csv_reporter import ReportCsv +from anta.reporter.md_reporter import MDReportGenerator from anta.runner import main if TYPE_CHECKING: @@ -141,6 +142,22 @@ def save_to_csv(ctx: click.Context, csv_file: pathlib.Path) -> None: ctx.exit(ExitCode.USAGE_ERROR) +def save_markdown_report(ctx: click.Context, md_output: pathlib.Path) -> None: + """Save the markdown report to a file. + + Parameters + ---------- + ctx: Click context containing the result manager. + md_output: Path to save the markdown report. + """ + try: + MDReportGenerator.generate(results=_get_result_manager(ctx), md_filename=md_output) + console.print(f"Markdown report saved to {md_output} ✅", style="cyan") + except OSError: + console.print(f"Failed to save Markdown report to {md_output} ❌", style="cyan") + ctx.exit(ExitCode.USAGE_ERROR) + + # Adding our own ANTA spinner - overriding rich SPINNERS for our own # so ignore warning for redefinition rich.spinner.SPINNERS = { # type: ignore[attr-defined] diff --git a/anta/constants.py b/anta/constants.py new file mode 100644 index 000000000..175a4adcc --- /dev/null +++ b/anta/constants.py @@ -0,0 +1,19 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Constants used in ANTA.""" + +from __future__ import annotations + +ACRONYM_CATEGORIES: set[str] = {"aaa", "mlag", "snmp", "bgp", "ospf", "vxlan", "stp", "igmp", "ip", "lldp", "ntp", "bfd", "ptp", "lanz", "stun", "vlan"} +"""A set of network protocol or feature acronyms that should be represented in uppercase.""" + +MD_REPORT_TOC = """**Table of Contents:** + +- [ANTA Report](#anta-report) + - [Test Results Summary](#test-results-summary) + - [Summary Totals](#summary-totals) + - [Summary Totals Device Under Test](#summary-totals-device-under-test) + - [Summary Totals Per Category](#summary-totals-per-category) + - [Test Results](#test-results)""" +"""Table of Contents for the Markdown report.""" diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index 7c911f243..c4e4f7bcf 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -154,21 +154,15 @@ def report_summary_tests( self.Headers.list_of_error_nodes, ] table = self._build_headers(headers=headers, table=table) - for test in manager.get_tests(): + for test, stats in sorted(manager.test_stats.items()): if tests is None or test in tests: - results = manager.filter_by_tests({test}).results - nb_failure = len([result for result in results if result.result == "failure"]) - nb_error = len([result for result in results if result.result == "error"]) - list_failure = [result.name for result in results if result.result in ["failure", "error"]] - nb_success = len([result for result in results if result.result == "success"]) - nb_skipped = len([result for result in results if result.result == "skipped"]) table.add_row( test, - str(nb_success), - str(nb_skipped), - str(nb_failure), - str(nb_error), - str(list_failure), + str(stats.devices_success_count), + str(stats.devices_skipped_count), + str(stats.devices_failure_count), + str(stats.devices_error_count), + ", ".join(stats.devices_failure), ) return table @@ -202,21 +196,15 @@ def report_summary_devices( self.Headers.list_of_error_tests, ] table = self._build_headers(headers=headers, table=table) - for device in manager.get_devices(): + for device, stats in sorted(manager.device_stats.items()): if devices is None or device in devices: - results = manager.filter_by_devices({device}).results - nb_failure = len([result for result in results if result.result == "failure"]) - nb_error = len([result for result in results if result.result == "error"]) - list_failure = [result.test for result in results if result.result in ["failure", "error"]] - nb_success = len([result for result in results if result.result == "success"]) - nb_skipped = len([result for result in results if result.result == "skipped"]) table.add_row( device, - str(nb_success), - str(nb_skipped), - str(nb_failure), - str(nb_error), - str(list_failure), + str(stats.tests_success_count), + str(stats.tests_skipped_count), + str(stats.tests_failure_count), + str(stats.tests_error_count), + ", ".join(stats.tests_failure), ) return table diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py new file mode 100644 index 000000000..0cc5b03e2 --- /dev/null +++ b/anta/reporter/md_reporter.py @@ -0,0 +1,287 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Markdown report generator for ANTA test results.""" + +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, ClassVar + +from anta.constants import MD_REPORT_TOC +from anta.logger import anta_log_exception + +if TYPE_CHECKING: + from collections.abc import Generator + from io import TextIOWrapper + from pathlib import Path + + from anta.result_manager import ResultManager + +logger = logging.getLogger(__name__) + + +# pylint: disable=too-few-public-methods +class MDReportGenerator: + """Class responsible for generating a Markdown report based on the provided `ResultManager` object. + + It aggregates different report sections, each represented by a subclass of `MDReportBase`, + and sequentially generates their content into a markdown file. + + The `generate` class method will loop over all the section subclasses and call their `generate_section` method. + The final report will be generated in the same order as the `sections` list of the method. + """ + + @classmethod + def generate(cls, results: ResultManager, md_filename: Path) -> None: + """Generate and write the various sections of the markdown report. + + Parameters + ---------- + results: The ResultsManager instance containing all test results. + md_filename: The path to the markdown file to write the report into. + """ + try: + with md_filename.open("w", encoding="utf-8") as mdfile: + sections: list[MDReportBase] = [ + ANTAReport(mdfile, results), + TestResultsSummary(mdfile, results), + SummaryTotals(mdfile, results), + SummaryTotalsDeviceUnderTest(mdfile, results), + SummaryTotalsPerCategory(mdfile, results), + TestResults(mdfile, results), + ] + for section in sections: + section.generate_section() + except OSError as exc: + message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'." + anta_log_exception(exc, message, logger) + raise + + +class MDReportBase(ABC): + """Base class for all sections subclasses. + + Every subclasses must implement the `generate_section` method that uses the `ResultManager` object + to generate and write content to the provided markdown file. + """ + + def __init__(self, mdfile: TextIOWrapper, results: ResultManager) -> None: + """Initialize the MDReportBase with an open markdown file object to write to and a ResultManager instance. + + Parameters + ---------- + mdfile: An open file object to write the markdown data into. + results: The ResultsManager instance containing all test results. + """ + self.mdfile = mdfile + self.results = results + + @abstractmethod + def generate_section(self) -> None: + """Abstract method to generate a specific section of the markdown report. + + Must be implemented by subclasses. + """ + msg = "Must be implemented by subclasses" + raise NotImplementedError(msg) + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of a markdown table for a specific report section. + + Subclasses can implement this method to generate the content of the table rows. + """ + msg = "Subclasses should implement this method" + raise NotImplementedError(msg) + + def generate_heading_name(self) -> str: + """Generate a formatted heading name based on the class name. + + Returns + ------- + str: Formatted header name. + + Example + ------- + - `ANTAReport` will become ANTA Report. + - `TestResultsSummary` will become Test Results Summary. + """ + class_name = self.__class__.__name__ + + # Split the class name into words, keeping acronyms together + words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+", class_name) + + # Capitalize each word, but keep acronyms in all caps + formatted_words = [word if word.isupper() else word.capitalize() for word in words] + + return " ".join(formatted_words) + + def write_table(self, table_heading: list[str], *, last_table: bool = False) -> None: + """Write a markdown table with a table heading and multiple rows to the markdown file. + + Parameters + ---------- + table_heading: List of strings to join for the table heading. + last_table: Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False. + """ + self.mdfile.write("\n".join(table_heading) + "\n") + for row in self.generate_rows(): + self.mdfile.write(row) + if not last_table: + self.mdfile.write("\n") + + def write_heading(self, heading_level: int) -> None: + """Write a markdown heading to the markdown file. + + The heading name used is the class name. + + Parameters + ---------- + heading_level: The level of the heading (1-6). + + Example + ------- + ## Test Results Summary + """ + # Ensure the heading level is within the valid range of 1 to 6 + heading_level = max(1, min(heading_level, 6)) + heading_name = self.generate_heading_name() + heading = "#" * heading_level + " " + heading_name + self.mdfile.write(f"{heading}\n\n") + + def safe_markdown(self, text: str | None) -> str: + """Escape markdown characters in the text to prevent markdown rendering issues. + + Parameters + ---------- + text: The text to escape markdown characters from. + + Returns + ------- + str: The text with escaped markdown characters. + """ + # Custom field from a TestResult object can be None + if text is None: + return "" + + # Replace newlines with spaces to keep content on one line + text = text.replace("\n", " ") + + # Replace backticks with single quotes + return text.replace("`", "'") + + +class ANTAReport(MDReportBase): + """Generate the `# ANTA Report` section of the markdown report.""" + + def generate_section(self) -> None: + """Generate the `# ANTA Report` section of the markdown report.""" + self.write_heading(heading_level=1) + toc = MD_REPORT_TOC + self.mdfile.write(toc + "\n\n") + + +class TestResultsSummary(MDReportBase): + """Generate the `## Test Results Summary` section of the markdown report.""" + + def generate_section(self) -> None: + """Generate the `## Test Results Summary` section of the markdown report.""" + self.write_heading(heading_level=2) + + +class SummaryTotals(MDReportBase): + """Generate the `### Summary Totals` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error |", + "| ----------- | ------------------- | ------------------- | ------------------- | ------------------|", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals table.""" + yield ( + f"| {self.results.get_total_results()} " + f"| {self.results.get_total_results({'success'})} " + f"| {self.results.get_total_results({'skipped'})} " + f"| {self.results.get_total_results({'failure'})} " + f"| {self.results.get_total_results({'error'})} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class SummaryTotalsDeviceUnderTest(MDReportBase): + """Generate the `### Summary Totals Devices Under Tests` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed |", + "| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------|", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals device under test table.""" + for device, stat in self.results.device_stats.items(): + total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + categories_skipped = ", ".join(sorted(stat.categories_skipped)) + categories_failed = ", ".join(sorted(stat.categories_failed)) + yield ( + f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} " + f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals Devices Under Tests` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class SummaryTotalsPerCategory(MDReportBase): + """Generate the `### Summary Totals Per Category` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error |", + "| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- |", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the summary totals per category table.""" + for category, stat in self.results.sorted_category_stats.items(): + total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + yield ( + f"| {category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} " + f"| {stat.tests_error_count} |\n" + ) + + def generate_section(self) -> None: + """Generate the `### Summary Totals Per Category` section of the markdown report.""" + self.write_heading(heading_level=3) + self.write_table(table_heading=self.TABLE_HEADING) + + +class TestResults(MDReportBase): + """Generates the `## Test Results` section of the markdown report.""" + + TABLE_HEADING: ClassVar[list[str]] = [ + "| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages |", + "| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- |", + ] + + def generate_rows(self) -> Generator[str, None, None]: + """Generate the rows of the all test results table.""" + for result in self.results.get_results(sort_by=["name", "test"]): + messages = self.safe_markdown(", ".join(result.messages)) + categories = ", ".join(result.categories) + yield ( + f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} " + f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n" + ) + + def generate_section(self) -> None: + """Generate the `## Test Results` section of the markdown report.""" + self.write_heading(heading_level=2) + self.write_table(table_heading=self.TABLE_HEADING, last_table=True) diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 4278c0da3..1900a28b1 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -6,14 +6,18 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING +from collections import defaultdict +from functools import cached_property +from itertools import chain +from typing import get_args from pydantic import TypeAdapter +from anta.constants import ACRONYM_CATEGORIES from anta.custom_types import TestStatus +from anta.result_manager.models import TestResult -if TYPE_CHECKING: - from anta.result_manager.models import TestResult +from .models import CategoryStats, DeviceStats, TestStats class ResultManager: @@ -94,6 +98,10 @@ def __init__(self) -> None: self.status: TestStatus = "unset" self.error_status = False + self.device_stats: defaultdict[str, DeviceStats] = defaultdict(DeviceStats) + self.category_stats: defaultdict[str, CategoryStats] = defaultdict(CategoryStats) + self.test_stats: defaultdict[str, TestStats] = defaultdict(TestStats) + def __len__(self) -> int: """Implement __len__ method to count number of results.""" return len(self._result_entries) @@ -105,38 +113,147 @@ def results(self) -> list[TestResult]: @results.setter def results(self, value: list[TestResult]) -> None: + """Set the list of TestResult.""" + # When setting the results, we need to reset the state of the current instance self._result_entries = [] self.status = "unset" self.error_status = False - for e in value: - self.add(e) + + # Also reset the stats attributes + self.device_stats = defaultdict(DeviceStats) + self.category_stats = defaultdict(CategoryStats) + self.test_stats = defaultdict(TestStats) + + for result in value: + self.add(result) @property def json(self) -> str: """Get a JSON representation of the results.""" return json.dumps([result.model_dump() for result in self._result_entries], indent=4) + @property + def sorted_category_stats(self) -> dict[str, CategoryStats]: + """A property that returns the category_stats dictionary sorted by key name.""" + return dict(sorted(self.category_stats.items())) + + @cached_property + def results_by_status(self) -> dict[TestStatus, list[TestResult]]: + """A cached property that returns the results grouped by status.""" + return {status: [result for result in self._result_entries if result.result == status] for status in get_args(TestStatus)} + + def _update_status(self, test_status: TestStatus) -> None: + """Update the status of the ResultManager instance based on the test status. + + Parameters + ---------- + test_status: TestStatus to update the ResultManager status. + """ + result_validator: TypeAdapter[TestStatus] = TypeAdapter(TestStatus) + result_validator.validate_python(test_status) + if test_status == "error": + self.error_status = True + return + if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: + self.status = test_status + elif self.status == "success" and test_status == "failure": + self.status = "failure" + + def _update_stats(self, result: TestResult) -> None: + """Update the statistics based on the test result. + + Parameters + ---------- + result: TestResult to update the statistics. + """ + result.categories = [ + " ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in result.categories + ] + count_attr = f"tests_{result.result}_count" + + # Update device stats + device_stats: DeviceStats = self.device_stats[result.name] + setattr(device_stats, count_attr, getattr(device_stats, count_attr) + 1) + if result.result in ("failure", "error"): + device_stats.tests_failure.add(result.test) + device_stats.categories_failed.update(result.categories) + elif result.result == "skipped": + device_stats.categories_skipped.update(result.categories) + + # Update category stats + for category in result.categories: + category_stats: CategoryStats = self.category_stats[category] + setattr(category_stats, count_attr, getattr(category_stats, count_attr) + 1) + + # Update test stats + count_attr = f"devices_{result.result}_count" + test_stats: TestStats = self.test_stats[result.test] + setattr(test_stats, count_attr, getattr(test_stats, count_attr) + 1) + if result.result in ("failure", "error"): + test_stats.devices_failure.add(result.name) + def add(self, result: TestResult) -> None: """Add a result to the ResultManager instance. + The result is added to the internal list of results and the overall status + of the ResultManager instance is updated based on the added test status. + Parameters ---------- result: TestResult to add to the ResultManager instance. """ + self._result_entries.append(result) + self._update_status(result.result) + self._update_stats(result) - def _update_status(test_status: TestStatus) -> None: - result_validator: TypeAdapter[TestStatus] = TypeAdapter(TestStatus) - result_validator.validate_python(test_status) - if test_status == "error": - self.error_status = True - return - if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: - self.status = test_status - elif self.status == "success" and test_status == "failure": - self.status = "failure" + # Every time a new result is added, we need to clear the cached property + self.__dict__.pop("results_by_status", None) - self._result_entries.append(result) - _update_status(result.result) + def get_results(self, status: set[TestStatus] | None = None, sort_by: list[str] | None = None) -> list[TestResult]: + """Get the results, optionally filtered by status and sorted by TestResult fields. + + If no status is provided, all results are returned. + + Parameters + ---------- + status: Optional set of TestStatus literals to filter the results. + sort_by: Optional list of TestResult fields to sort the results. + + Returns + ------- + List of TestResult. + """ + # Return all results if no status is provided, otherwise return results for multiple statuses + results = self._result_entries if status is None else list(chain.from_iterable(self.results_by_status.get(status, []) for status in status)) + + if sort_by: + accepted_fields = TestResult.model_fields.keys() + if not set(sort_by).issubset(set(accepted_fields)): + msg = f"Invalid sort_by fields: {sort_by}. Accepted fields are: {list(accepted_fields)}" + raise ValueError(msg) + results = sorted(results, key=lambda result: [getattr(result, field) for field in sort_by]) + + return results + + def get_total_results(self, status: set[TestStatus] | None = None) -> int: + """Get the total number of results, optionally filtered by status. + + If no status is provided, the total number of results is returned. + + Parameters + ---------- + status: Optional set of TestStatus literals to filter the results. + + Returns + ------- + Total number of results. + """ + if status is None: + # Return the total number of results + return sum(len(results) for results in self.results_by_status.values()) + + # Return the total number of results for multiple statuses + return sum(len(self.results_by_status.get(status, [])) for status in status) def get_status(self, *, ignore_error: bool = False) -> str: """Return the current status including error_status if ignore_error is False.""" @@ -153,8 +270,9 @@ def filter(self, hide: set[TestStatus]) -> ResultManager: ------- A filtered `ResultManager`. """ + possible_statuses = set(get_args(TestStatus)) manager = ResultManager() - manager.results = [test for test in self._result_entries if test.result not in hide] + manager.results = self.get_results(possible_statuses - hide) return manager def filter_by_tests(self, tests: set[str]) -> ResultManager: diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index e1171c88a..6abce0233 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -5,6 +5,8 @@ from __future__ import annotations +from dataclasses import dataclass, field + from pydantic import BaseModel from anta.custom_types import TestStatus @@ -89,3 +91,42 @@ def _set_status(self, status: TestStatus, message: str | None = None) -> None: def __str__(self) -> str: """Return a human readable string of this TestResult.""" return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" + + +# Pylint does not treat dataclasses differently: https://github.com/pylint-dev/pylint/issues/9058 +# pylint: disable=too-many-instance-attributes +@dataclass +class DeviceStats: + """Device statistics for a run of tests.""" + + tests_success_count: int = 0 + tests_skipped_count: int = 0 + tests_failure_count: int = 0 + tests_error_count: int = 0 + tests_unset_count: int = 0 + tests_failure: set[str] = field(default_factory=set) + categories_failed: set[str] = field(default_factory=set) + categories_skipped: set[str] = field(default_factory=set) + + +@dataclass +class CategoryStats: + """Category statistics for a run of tests.""" + + tests_success_count: int = 0 + tests_skipped_count: int = 0 + tests_failure_count: int = 0 + tests_error_count: int = 0 + tests_unset_count: int = 0 + + +@dataclass +class TestStats: + """Test statistics for a run of tests.""" + + devices_success_count: int = 0 + devices_skipped_count: int = 0 + devices_failure_count: int = 0 + devices_error_count: int = 0 + devices_unset_count: int = 0 + devices_failure: set[str] = field(default_factory=set) diff --git a/docs/cli/nrfu.md b/docs/cli/nrfu.md index 2f4e7eedc..0de782551 100644 --- a/docs/cli/nrfu.md +++ b/docs/cli/nrfu.md @@ -45,7 +45,7 @@ Options `--device` and `--test` can be used to target one or multiple devices an ### Hide results -Option `--hide` can be used to hide test results in the output based on their status. The option can be repeated. Example: `anta nrfu --hide error --hide skipped`. +Option `--hide` can be used to hide test results in the output or report file based on their status. The option can be repeated. Example: `anta nrfu --hide error --hide skipped`. ## Performing NRFU with text rendering @@ -167,6 +167,29 @@ Options: ![anta nrfu csv results](../imgs/anta_nrfu_csv.png){ loading=lazy width="1600" } +## Performing NRFU and saving results in a Markdown file + +The `md-report` command in NRFU testing generates a comprehensive Markdown report containing various sections, including detailed statistics for devices and test categories. + +### Command overview + +```bash +anta nrfu md-report --help + +Usage: anta nrfu md-report [OPTIONS] + + ANTA command to check network state with Markdown report. + +Options: + --md-output FILE Path to save the report as a Markdown file [env var: + ANTA_NRFU_MD_REPORT_MD_OUTPUT; required] + --help Show this message and exit. +``` + +### Example + +![anta nrfu md-report results](../imgs/anta-nrfu-md-report-output.png){ loading=lazy width="1600" } + ## Performing NRFU with custom reports ANTA offers a CLI option for creating custom reports. This leverages the Jinja2 template system, allowing you to tailor reports to your specific needs. diff --git a/docs/imgs/anta-nrfu-md-report-output.png b/docs/imgs/anta-nrfu-md-report-output.png new file mode 100644 index 0000000000000000000000000000000000000000..984e76b5c6eab6d4b195cf3ddb60d8a63b379c9c GIT binary patch literal 165566 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�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%0A1j5MtH`efDNx%`@NqR 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 literal 0 HcmV?d00001 diff --git a/docs/snippets/anta_nrfu_help.txt b/docs/snippets/anta_nrfu_help.txt index 365da0474..cb23fa7ed 100644 --- a/docs/snippets/anta_nrfu_help.txt +++ b/docs/snippets/anta_nrfu_help.txt @@ -53,7 +53,8 @@ Options: Commands: csv ANTA command to check network state with CSV report. - json ANTA command to check network state with JSON result. - table ANTA command to check network states with table result. - text ANTA command to check network states with text result. + json ANTA command to check network state with JSON results. + md-report ANTA command to check network state with Markdown report. + table ANTA command to check network state with table results. + text ANTA command to check network state with text results. tpl-report ANTA command to check network state with templated report. diff --git a/tests/data/test_md_report.md b/tests/data/test_md_report.md new file mode 100644 index 000000000..9360dbc74 --- /dev/null +++ b/tests/data/test_md_report.md @@ -0,0 +1,79 @@ +# ANTA Report + +**Table of Contents:** + +- [ANTA Report](#anta-report) + - [Test Results Summary](#test-results-summary) + - [Summary Totals](#summary-totals) + - [Summary Totals Device Under Test](#summary-totals-device-under-test) + - [Summary Totals Per Category](#summary-totals-per-category) + - [Test Results](#test-results) + +## Test Results Summary + +### Summary Totals + +| Total Tests | Total Tests Success | Total Tests Skipped | Total Tests Failure | Total Tests Error | +| ----------- | ------------------- | ------------------- | ------------------- | ------------------| +| 30 | 7 | 2 | 19 | 2 | + +### Summary Totals Device Under Test + +| Device Under Test | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | Categories Skipped | Categories Failed | +| ------------------| ----------- | ------------- | ------------- | ------------- | ----------- | -------------------| ------------------| +| DC1-SPINE1 | 15 | 2 | 2 | 10 | 1 | MLAG, VXLAN | AAA, BFD, BGP, Connectivity, Routing, SNMP, STP, Services, Software, System | +| DC1-LEAF1A | 15 | 5 | 0 | 9 | 1 | - | AAA, BFD, BGP, Connectivity, SNMP, STP, Services, Software, System | + +### Summary Totals Per Category + +| Test Category | Total Tests | Tests Success | Tests Skipped | Tests Failure | Tests Error | +| ------------- | ----------- | ------------- | ------------- | ------------- | ----------- | +| AAA | 2 | 0 | 0 | 2 | 0 | +| BFD | 2 | 0 | 0 | 2 | 0 | +| BGP | 2 | 0 | 0 | 2 | 0 | +| Connectivity | 4 | 0 | 0 | 2 | 2 | +| Interfaces | 2 | 2 | 0 | 0 | 0 | +| MLAG | 2 | 1 | 1 | 0 | 0 | +| Routing | 2 | 1 | 0 | 1 | 0 | +| SNMP | 2 | 0 | 0 | 2 | 0 | +| STP | 2 | 0 | 0 | 2 | 0 | +| Security | 2 | 2 | 0 | 0 | 0 | +| Services | 2 | 0 | 0 | 2 | 0 | +| Software | 2 | 0 | 0 | 2 | 0 | +| System | 2 | 0 | 0 | 2 | 0 | +| VXLAN | 2 | 1 | 1 | 0 | 0 | + +## Test Results + +| Device Under Test | Categories | Test | Description | Custom Field | Result | Messages | +| ----------------- | ---------- | ---- | ----------- | ------------ | ------ | -------- | +| DC1-LEAF1A | BFD | VerifyBFDSpecificPeers | Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF. | - | failure | Following BFD peers are not configured, status is not up or remote disc is zero: {'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}} | +| DC1-LEAF1A | BGP | VerifyBGPPeerCount | Verifies the count of BGP peers. | - | failure | Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 2, Actual: 1'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Expected: 3, Actual: 0'}}] | +| DC1-LEAF1A | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | device is running version "4.31.1F-34554157.4311F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F'] | +| DC1-LEAF1A | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Expected 's1-spine1' as the hostname, but found 'DC1-LEAF1A' instead. | +| DC1-LEAF1A | Interfaces | VerifyInterfaceUtilization | Verifies that the utilization of interfaces is below a certain threshold. | - | success | - | +| DC1-LEAF1A | Connectivity | VerifyLLDPNeighbors | Verifies that the provided LLDP neighbors are connected properly. | - | failure | Wrong LLDP neighbor(s) on port(s): Ethernet1 DC1-SPINE1_Ethernet1 Ethernet2 DC1-SPINE2_Ethernet1 Port(s) not configured: Ethernet7 | +| DC1-LEAF1A | MLAG | VerifyMlagStatus | Verifies the health status of the MLAG configuration. | - | success | - | +| DC1-LEAF1A | System | VerifyNTP | Verifies if NTP is synchronised. | - | failure | The device is not synchronized with the configured NTP server(s): 'NTP is disabled.' | +| DC1-LEAF1A | Connectivity | VerifyReachability | Test the network reachability to one or many destination IP(s). | - | error | ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1 | +| DC1-LEAF1A | Routing | VerifyRoutingTableEntry | Verifies that the provided routes are present in the routing table of a specified VRF. | - | success | - | +| DC1-LEAF1A | STP | VerifySTPMode | Verifies the configured STP mode for a provided list of VLAN(s). | - | failure | Wrong STP mode configured for the following VLAN(s): [10, 20] | +| DC1-LEAF1A | SNMP | VerifySnmpStatus | Verifies if the SNMP agent is enabled. | - | failure | SNMP agent disabled in vrf default | +| DC1-LEAF1A | AAA | VerifyTacacsSourceIntf | Verifies TACACS source-interface for a specified VRF. | - | failure | Source-interface Management0 is not configured in VRF default | +| DC1-LEAF1A | Security | VerifyTelnetStatus | Verifies if Telnet is disabled in the default VRF. | - | success | - | +| DC1-LEAF1A | VXLAN | VerifyVxlan1Interface | Verifies the Vxlan1 interface status. | - | success | - | +| DC1-SPINE1 | BFD | VerifyBFDSpecificPeers | Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF. | - | failure | Following BFD peers are not configured, status is not up or remote disc is zero: {'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}} | +| DC1-SPINE1 | BGP | VerifyBGPPeerCount | Verifies the count of BGP peers. | - | failure | Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Not Configured', 'default': 'Expected: 3, Actual: 4'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, {'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 4'}}] | +| DC1-SPINE1 | Software | VerifyEOSVersion | Verifies the EOS version of the device. | - | failure | device is running version "4.31.1F-34554157.4311F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F'] | +| DC1-SPINE1 | Services | VerifyHostname | Verifies the hostname of a device. | - | failure | Expected 's1-spine1' as the hostname, but found 'DC1-SPINE1' instead. | +| DC1-SPINE1 | Interfaces | VerifyInterfaceUtilization | Verifies that the utilization of interfaces is below a certain threshold. | - | success | - | +| DC1-SPINE1 | Connectivity | VerifyLLDPNeighbors | Verifies that the provided LLDP neighbors are connected properly. | - | failure | Wrong LLDP neighbor(s) on port(s): Ethernet1 DC1-LEAF1A_Ethernet1 Ethernet2 DC1-LEAF1B_Ethernet1 Port(s) not configured: Ethernet7 | +| DC1-SPINE1 | MLAG | VerifyMlagStatus | Verifies the health status of the MLAG configuration. | - | skipped | MLAG is disabled | +| DC1-SPINE1 | System | VerifyNTP | Verifies if NTP is synchronised. | - | failure | The device is not synchronized with the configured NTP server(s): 'NTP is disabled.' | +| DC1-SPINE1 | Connectivity | VerifyReachability | Test the network reachability to one or many destination IP(s). | - | error | ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1 | +| DC1-SPINE1 | Routing | VerifyRoutingTableEntry | Verifies that the provided routes are present in the routing table of a specified VRF. | - | failure | The following route(s) are missing from the routing table of VRF default: ['10.1.0.2'] | +| DC1-SPINE1 | STP | VerifySTPMode | Verifies the configured STP mode for a provided list of VLAN(s). | - | failure | STP mode 'rapidPvst' not configured for the following VLAN(s): [10, 20] | +| DC1-SPINE1 | SNMP | VerifySnmpStatus | Verifies if the SNMP agent is enabled. | - | failure | SNMP agent disabled in vrf default | +| DC1-SPINE1 | AAA | VerifyTacacsSourceIntf | Verifies TACACS source-interface for a specified VRF. | - | failure | Source-interface Management0 is not configured in VRF default | +| DC1-SPINE1 | Security | VerifyTelnetStatus | Verifies if Telnet is disabled in the default VRF. | - | success | - | +| DC1-SPINE1 | VXLAN | VerifyVxlan1Interface | Verifies the Vxlan1 interface status. | - | skipped | Vxlan1 interface is not configured | diff --git a/tests/data/test_md_report_results.json b/tests/data/test_md_report_results.json new file mode 100644 index 000000000..b9ecc0c57 --- /dev/null +++ b/tests/data/test_md_report_results.json @@ -0,0 +1,378 @@ +[ + { + "name": "DC1-SPINE1", + "test": "VerifyTacacsSourceIntf", + "categories": [ + "AAA" + ], + "description": "Verifies TACACS source-interface for a specified VRF.", + "result": "failure", + "messages": [ + "Source-interface Management0 is not configured in VRF default" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyLLDPNeighbors", + "categories": [ + "Connectivity" + ], + "description": "Verifies that the provided LLDP neighbors are connected properly.", + "result": "failure", + "messages": [ + "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-LEAF1A_Ethernet1\n Ethernet2\n DC1-LEAF1B_Ethernet1\nPort(s) not configured:\n Ethernet7" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyBGPPeerCount", + "categories": [ + "BGP" + ], + "description": "Verifies the count of BGP peers.", + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Not Configured', 'default': 'Expected: 3, Actual: 4'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, {'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 4'}}]" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifySTPMode", + "categories": [ + "STP" + ], + "description": "Verifies the configured STP mode for a provided list of VLAN(s).", + "result": "failure", + "messages": [ + "STP mode 'rapidPvst' not configured for the following VLAN(s): [10, 20]" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifySnmpStatus", + "categories": [ + "SNMP" + ], + "description": "Verifies if the SNMP agent is enabled.", + "result": "failure", + "messages": [ + "SNMP agent disabled in vrf default" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyRoutingTableEntry", + "categories": [ + "Routing" + ], + "description": "Verifies that the provided routes are present in the routing table of a specified VRF.", + "result": "failure", + "messages": [ + "The following route(s) are missing from the routing table of VRF default: ['10.1.0.2']" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyInterfaceUtilization", + "categories": [ + "Interfaces" + ], + "description": "Verifies that the utilization of interfaces is below a certain threshold.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyMlagStatus", + "categories": [ + "MLAG" + ], + "description": "Verifies the health status of the MLAG configuration.", + "result": "skipped", + "messages": [ + "MLAG is disabled" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyVxlan1Interface", + "categories": [ + "VXLAN" + ], + "description": "Verifies the Vxlan1 interface status.", + "result": "skipped", + "messages": [ + "Vxlan1 interface is not configured" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyBFDSpecificPeers", + "categories": [ + "BFD" + ], + "description": "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF.", + "result": "failure", + "messages": [ + "Following BFD peers are not configured, status is not up or remote disc is zero:\n{'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}}" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyNTP", + "categories": [ + "System" + ], + "description": "Verifies if NTP is synchronised.", + "result": "failure", + "messages": [ + "The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyReachability", + "categories": [ + "Connectivity" + ], + "description": "Test the network reachability to one or many destination IP(s).", + "result": "error", + "messages": [ + "ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyTelnetStatus", + "categories": [ + "Security" + ], + "description": "Verifies if Telnet is disabled in the default VRF.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyEOSVersion", + "categories": [ + "Software" + ], + "description": "Verifies the EOS version of the device.", + "result": "failure", + "messages": [ + "device is running version \"4.31.1F-34554157.4311F (engineering build)\" not in expected versions: ['4.25.4M', '4.26.1F']" + ], + "custom_field": null + }, + { + "name": "DC1-SPINE1", + "test": "VerifyHostname", + "categories": [ + "Services" + ], + "description": "Verifies the hostname of a device.", + "result": "failure", + "messages": [ + "Expected `s1-spine1` as the hostname, but found `DC1-SPINE1` instead." + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyTacacsSourceIntf", + "categories": [ + "AAA" + ], + "description": "Verifies TACACS source-interface for a specified VRF.", + "result": "failure", + "messages": [ + "Source-interface Management0 is not configured in VRF default" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyLLDPNeighbors", + "categories": [ + "Connectivity" + ], + "description": "Verifies that the provided LLDP neighbors are connected properly.", + "result": "failure", + "messages": [ + "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-SPINE1_Ethernet1\n Ethernet2\n DC1-SPINE2_Ethernet1\nPort(s) not configured:\n Ethernet7" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyBGPPeerCount", + "categories": [ + "BGP" + ], + "description": "Verifies the count of BGP peers.", + "result": "failure", + "messages": [ + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 2, Actual: 1'}}, {'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'DEV': 'Expected: 3, Actual: 0'}}]" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifySTPMode", + "categories": [ + "STP" + ], + "description": "Verifies the configured STP mode for a provided list of VLAN(s).", + "result": "failure", + "messages": [ + "Wrong STP mode configured for the following VLAN(s): [10, 20]" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifySnmpStatus", + "categories": [ + "SNMP" + ], + "description": "Verifies if the SNMP agent is enabled.", + "result": "failure", + "messages": [ + "SNMP agent disabled in vrf default" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyRoutingTableEntry", + "categories": [ + "Routing" + ], + "description": "Verifies that the provided routes are present in the routing table of a specified VRF.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyInterfaceUtilization", + "categories": [ + "Interfaces" + ], + "description": "Verifies that the utilization of interfaces is below a certain threshold.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyMlagStatus", + "categories": [ + "MLAG" + ], + "description": "Verifies the health status of the MLAG configuration.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyVxlan1Interface", + "categories": [ + "VXLAN" + ], + "description": "Verifies the Vxlan1 interface status.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyBFDSpecificPeers", + "categories": [ + "BFD" + ], + "description": "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF.", + "result": "failure", + "messages": [ + "Following BFD peers are not configured, status is not up or remote disc is zero:\n{'192.0.255.8': {'default': 'Not Configured'}, '192.0.255.7': {'default': 'Not Configured'}}" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyNTP", + "categories": [ + "System" + ], + "description": "Verifies if NTP is synchronised.", + "result": "failure", + "messages": [ + "The device is not synchronized with the configured NTP server(s): 'NTP is disabled.'" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyReachability", + "categories": [ + "Connectivity" + ], + "description": "Test the network reachability to one or many destination IP(s).", + "result": "error", + "messages": [ + "ping vrf MGMT 1.1.1.1 source Management1 repeat 2 has failed: No source interface Management1" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyTelnetStatus", + "categories": [ + "Security" + ], + "description": "Verifies if Telnet is disabled in the default VRF.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyEOSVersion", + "categories": [ + "Software" + ], + "description": "Verifies the EOS version of the device.", + "result": "failure", + "messages": [ + "device is running version \"4.31.1F-34554157.4311F (engineering build)\" not in expected versions: ['4.25.4M', '4.26.1F']" + ], + "custom_field": null + }, + { + "name": "DC1-LEAF1A", + "test": "VerifyHostname", + "categories": [ + "Services" + ], + "description": "Verifies the hostname of a device.", + "result": "failure", + "messages": [ + "Expected `s1-spine1` as the hostname, but found `DC1-LEAF1A` instead." + ], + "custom_field": null + } +] diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index b0205b8bb..92210acfa 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -5,8 +5,10 @@ from __future__ import annotations +import json import logging import shutil +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable from unittest.mock import patch @@ -23,12 +25,15 @@ if TYPE_CHECKING: from collections.abc import Iterator - from pathlib import Path from anta.models import AntaCommand logger = logging.getLogger(__name__) +DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data" + +JSON_RESULTS = "test_md_report_results.json" + DEVICE_HW_MODEL = "pytest" DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" @@ -154,6 +159,31 @@ def _factory(number: int = 0) -> ResultManager: return _factory +@pytest.fixture +def result_manager() -> ResultManager: + """Return a ResultManager with 30 random tests loaded from a JSON file. + + Devices: DC1-SPINE1, DC1-LEAF1A + + - Total tests: 30 + - Success: 7 + - Skipped: 2 + - Failure: 19 + - Error: 2 + + See `tests/data/test_md_report_results.json` and `tests/data/test_md_report_all_tests.md` for details. + """ + manager = ResultManager() + + with (DATA_DIR / JSON_RESULTS).open("r", encoding="utf-8") as f: + results = json.load(f) + + for result in results: + manager.add(TestResult(**result)) + + return manager + + # tests.units.cli fixtures @pytest.fixture def temp_env(tmp_path: Path) -> dict[str, str | None]: diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index 83369f344..7227a699f 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -120,3 +120,9 @@ def test_disable_cache(click_runner: CliRunner) -> None: if "disable_cache" in line: assert "True" in line assert result.exit_code == ExitCode.OK + + +def test_hide(click_runner: CliRunner) -> None: + """Test the `--hide` option of the `anta nrfu` command.""" + result = click_runner.invoke(anta, ["nrfu", "--hide", "success", "text"]) + assert "SUCCESS" not in result.output diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 803c8f803..72d5a0154 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -151,3 +151,23 @@ def test_anta_nrfu_csv_failure(click_runner: CliRunner, tmp_path: Path) -> None: assert result.exit_code == ExitCode.USAGE_ERROR assert "Failed to save CSV report to" in result.output assert not csv_output.exists() + + +def test_anta_nrfu_md_report(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu md-report.""" + md_output = tmp_path / "test.md" + result = click_runner.invoke(anta, ["nrfu", "md-report", "--md-output", str(md_output)]) + assert result.exit_code == ExitCode.OK + assert "Markdown report saved to" in result.output + assert md_output.exists() + + +def test_anta_nrfu_md_report_failure(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu md-report failure.""" + md_output = tmp_path / "test.md" + with patch("anta.reporter.md_reporter.MDReportGenerator.generate", side_effect=OSError()): + result = click_runner.invoke(anta, ["nrfu", "md-report", "--md-output", str(md_output)]) + + assert result.exit_code == ExitCode.USAGE_ERROR + assert "Failed to save Markdown report to" in result.output + assert not md_output.exists() diff --git a/tests/units/reporter/test_md_reporter.py b/tests/units/reporter/test_md_reporter.py new file mode 100644 index 000000000..a60773374 --- /dev/null +++ b/tests/units/reporter/test_md_reporter.py @@ -0,0 +1,54 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Test anta.reporter.md_reporter.py.""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest + +from anta.reporter.md_reporter import MDReportBase, MDReportGenerator +from anta.result_manager import ResultManager + +DATA_DIR: Path = Path(__file__).parent.parent.parent.resolve() / "data" + + +def test_md_report_generate(tmp_path: Path, result_manager: ResultManager) -> None: + """Test the MDReportGenerator class.""" + md_filename = tmp_path / "test.md" + expected_report = "test_md_report.md" + + # Generate the Markdown report + MDReportGenerator.generate(result_manager, md_filename) + assert md_filename.exists() + + # Load the existing Markdown report to compare with the generated one + with (DATA_DIR / expected_report).open("r", encoding="utf-8") as f: + expected_content = f.read() + + # Check the content of the Markdown file + content = md_filename.read_text(encoding="utf-8") + + assert content == expected_content + + +def test_md_report_base() -> None: + """Test the MDReportBase class.""" + + class FakeMDReportBase(MDReportBase): + """Fake MDReportBase class.""" + + def generate_section(self) -> None: + pass + + results = ResultManager() + + with StringIO() as mock_file: + report = FakeMDReportBase(mock_file, results) + assert report.generate_heading_name() == "Fake MD Report Base" + + with pytest.raises(NotImplementedError, match="Subclasses should implement this method"): + report.generate_rows() diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py index 02c694c05..66a6cfb1d 100644 --- a/tests/units/result_manager/test__init__.py +++ b/tests/units/result_manager/test__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import re from contextlib import AbstractContextManager, nullcontext from typing import TYPE_CHECKING, Callable @@ -71,6 +72,27 @@ def test_json(self, list_result_factory: Callable[[int], list[TestResult]]) -> N assert test.get("custom_field") is None assert test.get("result") == "success" + def test_sorted_category_stats(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: + """Test ResultManager.sorted_category_stats.""" + result_manager = ResultManager() + results = list_result_factory(4) + + # Modify the categories to have a mix of different acronym categories + results[0].categories = ["ospf"] + results[1].categories = ["bgp"] + results[2].categories = ["vxlan"] + results[3].categories = ["system"] + + result_manager.results = results + + # Check the current categories order and name format + expected_order = ["OSPF", "BGP", "VXLAN", "System"] + assert list(result_manager.category_stats.keys()) == expected_order + + # Check the sorted categories order and name format + expected_order = ["BGP", "OSPF", "System", "VXLAN"] + assert list(result_manager.sorted_category_stats.keys()) == expected_order + @pytest.mark.parametrize( ("starting_status", "test_status", "expected_status", "expected_raise"), [ @@ -149,6 +171,91 @@ def test_add( assert result_manager.status == expected_status assert len(result_manager) == 1 + def test_add_clear_cache(self, result_manager: ResultManager, test_result_factory: Callable[[], TestResult]) -> None: + """Test ResultManager.add and make sure the cache is reset after adding a new test.""" + # Check the cache is empty + assert "results_by_status" not in result_manager.__dict__ + + # Access the cache + assert result_manager.get_total_results() == 30 + + # Check the cache is filled with the correct results count + assert "results_by_status" in result_manager.__dict__ + assert sum(len(v) for v in result_manager.__dict__["results_by_status"].values()) == 30 + + # Add a new test + result_manager.add(result=test_result_factory()) + + # Check the cache has been reset + assert "results_by_status" not in result_manager.__dict__ + + # Access the cache again + assert result_manager.get_total_results() == 31 + + # Check the cache is filled again with the correct results count + assert "results_by_status" in result_manager.__dict__ + assert sum(len(v) for v in result_manager.__dict__["results_by_status"].values()) == 31 + + def test_get_results(self, result_manager: ResultManager) -> None: + """Test ResultManager.get_results.""" + # Check for single status + success_results = result_manager.get_results(status={"success"}) + assert len(success_results) == 7 + assert all(r.result == "success" for r in success_results) + + # Check for multiple statuses + failure_results = result_manager.get_results(status={"failure", "error"}) + assert len(failure_results) == 21 + assert all(r.result in {"failure", "error"} for r in failure_results) + + # Check all results + all_results = result_manager.get_results() + assert len(all_results) == 30 + + def test_get_results_sort_by(self, result_manager: ResultManager) -> None: + """Test ResultManager.get_results with sort_by.""" + # Check all results with sort_by result + all_results = result_manager.get_results(sort_by=["result"]) + assert len(all_results) == 30 + assert [r.result for r in all_results] == ["error"] * 2 + ["failure"] * 19 + ["skipped"] * 2 + ["success"] * 7 + + # Check all results with sort_by device (name) + all_results = result_manager.get_results(sort_by=["name"]) + assert len(all_results) == 30 + assert all_results[0].name == "DC1-LEAF1A" + assert all_results[-1].name == "DC1-SPINE1" + + # Check multiple statuses with sort_by categories + success_skipped_results = result_manager.get_results(status={"success", "skipped"}, sort_by=["categories"]) + assert len(success_skipped_results) == 9 + assert success_skipped_results[0].categories == ["Interfaces"] + assert success_skipped_results[-1].categories == ["VXLAN"] + + # Check all results with bad sort_by + with pytest.raises( + ValueError, + match=re.escape( + "Invalid sort_by fields: ['bad_field']. Accepted fields are: ['name', 'test', 'categories', 'description', 'result', 'messages', 'custom_field']", + ), + ): + all_results = result_manager.get_results(sort_by=["bad_field"]) + + def test_get_total_results(self, result_manager: ResultManager) -> None: + """Test ResultManager.get_total_results.""" + # Test all results + assert result_manager.get_total_results() == 30 + + # Test single status + assert result_manager.get_total_results(status={"success"}) == 7 + assert result_manager.get_total_results(status={"failure"}) == 19 + assert result_manager.get_total_results(status={"error"}) == 2 + assert result_manager.get_total_results(status={"skipped"}) == 2 + + # Test multiple statuses + assert result_manager.get_total_results(status={"success", "failure"}) == 26 + assert result_manager.get_total_results(status={"success", "failure", "error"}) == 28 + assert result_manager.get_total_results(status={"success", "failure", "error", "skipped"}) == 30 + @pytest.mark.parametrize( ("status", "error_status", "ignore_error", "expected_status"), [ From 91a3c220a8155a55031f2a68f7487e50029fc0b5 Mon Sep 17 00:00:00 2001 From: Carl Baillargeon Date: Thu, 29 Aug 2024 08:28:15 -0400 Subject: [PATCH 19/20] test(anta): Add extra unit test for md-report (#807) --- tests/units/cli/nrfu/test_commands.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 72d5a0154..27f01a78c 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -171,3 +171,27 @@ def test_anta_nrfu_md_report_failure(click_runner: CliRunner, tmp_path: Path) -> assert result.exit_code == ExitCode.USAGE_ERROR assert "Failed to save Markdown report to" in result.output assert not md_output.exists() + + +def test_anta_nrfu_md_report_with_hide(click_runner: CliRunner, tmp_path: Path) -> None: + """Test anta nrfu md-report with the `--hide` option.""" + md_output = tmp_path / "test.md" + result = click_runner.invoke(anta, ["nrfu", "--hide", "success", "md-report", "--md-output", str(md_output)]) + + assert result.exit_code == ExitCode.OK + assert "Markdown report saved to" in result.output + assert md_output.exists() + + with md_output.open("r", encoding="utf-8") as f: + content = f.read() + + # Use regex to find the "Total Tests Success" value + match = re.search(r"\| (\d+) \| (\d+) \| \d+ \| \d+ \| \d+ \|", content) + + assert match is not None + + total_tests = int(match.group(1)) + total_tests_success = int(match.group(2)) + + assert total_tests == 0 + assert total_tests_success == 0 From 30f731cad7646c9cc3108a8afa94219070e1b11b Mon Sep 17 00:00:00 2001 From: vitthalmagadum <122079046+vitthalmagadum@users.noreply.github.com> Date: Thu, 29 Aug 2024 18:16:23 +0530 Subject: [PATCH 20/20] feat(anta): Updated VerifyBFDPeersIntervals for failure message to be in milliseconds like the inputs (#805) --- anta/tests/bfd.py | 24 ++++++++++++------------ tests/units/anta_tests/test_bfd.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index 0b171a6d2..f42d80de7 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -157,34 +157,34 @@ def test(self) -> None: for bfd_peers in self.inputs.bfd_peers: peer = str(bfd_peers.peer_address) vrf = bfd_peers.vrf - - # Converting milliseconds intervals into actual value - tx_interval = bfd_peers.tx_interval * 1000 - rx_interval = bfd_peers.rx_interval * 1000 + tx_interval = bfd_peers.tx_interval + rx_interval = bfd_peers.rx_interval multiplier = bfd_peers.multiplier + + # Check if BFD peer configured bfd_output = get_value( self.instance_commands[0].json_output, f"vrfs..{vrf}..ipv4Neighbors..{peer}..peerStats..", separator="..", ) - - # Check if BFD peer configured if not bfd_output: failures[peer] = {vrf: "Not Configured"} continue + # Convert interval timer(s) into milliseconds to be consistent with the inputs. bfd_details = bfd_output.get("peerStatsDetail", {}) - intervals_ok = ( - bfd_details.get("operTxInterval") == tx_interval and bfd_details.get("operRxInterval") == rx_interval and bfd_details.get("detectMult") == multiplier - ) + op_tx_interval = bfd_details.get("operTxInterval") // 1000 + op_rx_interval = bfd_details.get("operRxInterval") // 1000 + detect_multiplier = bfd_details.get("detectMult") + intervals_ok = op_tx_interval == tx_interval and op_rx_interval == rx_interval and detect_multiplier == multiplier # Check timers of BFD peer if not intervals_ok: failures[peer] = { vrf: { - "tx_interval": bfd_details.get("operTxInterval"), - "rx_interval": bfd_details.get("operRxInterval"), - "multiplier": bfd_details.get("detectMult"), + "tx_interval": op_tx_interval, + "rx_interval": op_rx_interval, + "multiplier": detect_multiplier, } } diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index b3ab5609a..3b1b8b86a 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -163,8 +163,8 @@ "result": "failure", "messages": [ "Following BFD peers are not configured or timers are not correct:\n" - "{'192.0.255.7': {'default': {'tx_interval': 1300000, 'rx_interval': 1200000, 'multiplier': 4}}, " - "'192.0.255.70': {'MGMT': {'tx_interval': 120000, 'rx_interval': 120000, 'multiplier': 5}}}" + "{'192.0.255.7': {'default': {'tx_interval': 1300, 'rx_interval': 1200, 'multiplier': 4}}, " + "'192.0.255.70': {'MGMT': {'tx_interval': 120, 'rx_interval': 120, 'multiplier': 5}}}" ], }, },