diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml index 3a66c5cf3..1d2473bdc 100644 --- a/.github/workflows/code-testing.yml +++ b/.github/workflows/code-testing.yml @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] needs: file-changes steps: - uses: actions/checkout@v4 @@ -108,7 +108,7 @@ jobs: needs: [lint-python, type-python] strategy: matrix: - python: ["3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Setup Python @@ -149,4 +149,4 @@ jobs: uses: CodSpeedHQ/action@v3 with: token: ${{ secrets.CODSPEED_TOKEN }} - run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark \ No newline at end of file + run: pytest --codspeed --no-cov --log-cli-level INFO tests/benchmark diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d6cd1863..e16c8e579 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: - '' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.8.0 hooks: - id: ruff name: Run Ruff linter @@ -85,7 +85,7 @@ repos: types: [text] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.13.0 hooks: - id: mypy name: Check typing with mypy @@ -100,7 +100,7 @@ repos: files: ^(anta|tests)/ - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.42.0 + rev: v0.43.0 hooks: - id: markdownlint name: Check Markdown files style. diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index b542a6d80..90be5c75c 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -35,7 +35,7 @@ def wrap() -> None: cli = build_cli(exc) -__all__ = ["cli", "anta"] +__all__ = ["anta", "cli"] if __name__ == "__main__": cli() diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 531614abd..ff36e56d2 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -84,7 +84,10 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat ) @click.option( "--configure", - help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.", + help=( + "[DEPRECATED] Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). " + "THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK." + ), default=False, is_flag=True, show_default=True, diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index ce13622a7..33a02220c 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -128,6 +128,13 @@ async def collect(device: AntaDevice) -> None: logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name) return + # TODO: ANTA 2.0.0 + msg = ( + "[DEPRECATED] Using '--configure' for collecting show-techs is deprecated and will be removed in ANTA 2.0.0. " + "Please add the required configuration on your devices before running this command from ANTA." + ) + logger.warning(msg) + commands = [] # TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case. # Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 947c08901..375e6e1ed 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -116,8 +116,12 @@ def print_text(ctx: click.Context) -> None: """Print results as simple text.""" console.print() for test in _get_result_manager(ctx).results: - message = f" ({test.messages[0]!s})" if len(test.messages) > 0 else "" - console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]{message}", highlight=False) + if len(test.messages) <= 1: + message = test.messages[0] if len(test.messages) == 1 else "" + console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]({message})", highlight=False) + else: # len(test.messages) > 1 + console.print(f"{test.name} :: {test.test} :: [{test.result}]{test.result.upper()}[/{test.result}]", highlight=False) + console.print("\n".join(f" {message}" for message in test.messages), highlight=False) def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib.Path | None = None) -> None: diff --git a/anta/input_models/__init__.py b/anta/input_models/__init__.py new file mode 100644 index 000000000..5b8974c76 --- /dev/null +++ b/anta/input_models/__init__.py @@ -0,0 +1,4 @@ +# 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. +"""Package related to all ANTA tests input models.""" diff --git a/anta/input_models/bfd.py b/anta/input_models/bfd.py new file mode 100644 index 000000000..9ccc6254a --- /dev/null +++ b/anta/input_models/bfd.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for BFD tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol + + +class BFDPeer(BaseModel): + """BFD (Bidirectional Forwarding Detection) model representing the peer details. + + Only IPv4 peers are supported for now. + """ + + model_config = ConfigDict(extra="forbid") + peer_address: IPv4Address + """IPv4 address of a BFD peer.""" + vrf: str = "default" + """Optional VRF for the BFD peer. Defaults to `default`.""" + tx_interval: BfdInterval | None = None + """Tx interval of BFD peer in milliseconds. Required field in the `VerifyBFDPeersIntervals` test.""" + rx_interval: BfdInterval | None = None + """Rx interval of BFD peer in milliseconds. Required field in the `VerifyBFDPeersIntervals` test.""" + multiplier: BfdMultiplier | None = None + """Multiplier of BFD peer. Required field in the `VerifyBFDPeersIntervals` test.""" + protocols: list[BfdProtocol] | None = None + """List of protocols to be verified. Required field in the `VerifyBFDPeersRegProtocols` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the BFDPeer for reporting.""" + return f"Peer: {self.peer_address} VRF: {self.vrf}" diff --git a/anta/input_models/connectivity.py b/anta/input_models/connectivity.py new file mode 100644 index 000000000..e8f555371 --- /dev/null +++ b/anta/input_models/connectivity.py @@ -0,0 +1,83 @@ +# 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. +"""Module containing input models for connectivity tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Any +from warnings import warn + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import Interface + + +class Host(BaseModel): + """Model for a remote host to ping.""" + + model_config = ConfigDict(extra="forbid") + destination: IPv4Address + """IPv4 address to ping.""" + source: IPv4Address | Interface + """IPv4 address source IP or egress interface to use.""" + vrf: str = "default" + """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 __str__(self) -> str: + """Return a human-readable string representation of the Host for reporting. + + Examples + -------- + Host 10.1.1.1 (src: 10.2.2.2, vrf: mgmt, size: 100B, repeat: 2) + + """ + df_status = ", df-bit: enabled" if self.df_bit else "" + return f"Host {self.destination} (src: {self.source}, vrf: {self.vrf}, size: {self.size}B, repeat: {self.repeat}{df_status})" + + +class LLDPNeighbor(BaseModel): + """LLDP (Link Layer Discovery Protocol) model representing the port details and neighbor information.""" + + model_config = ConfigDict(extra="forbid") + port: Interface + """The LLDP port for the local device.""" + neighbor_device: str + """The system name of the LLDP neighbor device.""" + neighbor_port: Interface + """The LLDP port on the neighboring device.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the LLDPNeighbor for reporting. + + Examples + -------- + Port Ethernet1 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet2) + + """ + return f"Port {self.port} (Neighbor: {self.neighbor_device}, Neighbor Port: {self.neighbor_port})" + + +class Neighbor(LLDPNeighbor): # pragma: no cover + """Alias for the LLDPNeighbor model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the LLDPNeighbor model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the LLDPNeighbor class, emitting a depreciation warning.""" + warn( + message="Neighbor model is deprecated and will be removed in ANTA v2.0.0. Use the LLDPNeighbor model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/interfaces.py b/anta/input_models/interfaces.py new file mode 100644 index 000000000..5036156de --- /dev/null +++ b/anta/input_models/interfaces.py @@ -0,0 +1,23 @@ +# 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. +"""Module containing input models for interface tests.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from anta.custom_types import Interface + + +class InterfaceState(BaseModel): + """Model for an interface state.""" + + name: Interface + """Interface to validate.""" + status: Literal["up", "down", "adminDown"] + """Expected status of the interface.""" + line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None + """Expected line protocol status of the interface.""" diff --git a/anta/input_models/routing/__init__.py b/anta/input_models/routing/__init__.py new file mode 100644 index 000000000..e1188cc9f --- /dev/null +++ b/anta/input_models/routing/__init__.py @@ -0,0 +1,4 @@ +# 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. +"""Package related to routing tests input models.""" diff --git a/anta/input_models/routing/bgp.py b/anta/input_models/routing/bgp.py new file mode 100644 index 000000000..a291809c6 --- /dev/null +++ b/anta/input_models/routing/bgp.py @@ -0,0 +1,130 @@ +# 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. +"""Module containing input models for routing BGP tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address +from typing import TYPE_CHECKING, Any +from warnings import warn + +from pydantic import BaseModel, ConfigDict, PositiveInt, model_validator + +from anta.custom_types import Afi, Safi + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + +AFI_SAFI_EOS_KEY = { + ("ipv4", "unicast"): "ipv4Unicast", + ("ipv4", "multicast"): "ipv4Multicast", + ("ipv4", "labeled-unicast"): "ipv4MplsLabels", + ("ipv4", "sr-te"): "ipv4SrTe", + ("ipv6", "unicast"): "ipv6Unicast", + ("ipv6", "multicast"): "ipv6Multicast", + ("ipv6", "labeled-unicast"): "ipv6MplsLabels", + ("ipv6", "sr-te"): "ipv6SrTe", + ("vpn-ipv4", None): "ipv4MplsVpn", + ("vpn-ipv6", None): "ipv6MplsVpn", + ("evpn", None): "l2VpnEvpn", + ("rt-membership", None): "rtMembership", + ("path-selection", None): "dps", + ("link-state", None): "linkState", +} +"""Dictionary mapping AFI/SAFI to EOS key representation.""" + + +class BgpAddressFamily(BaseModel): + """Model for a BGP address family.""" + + model_config = ConfigDict(extra="forbid") + afi: Afi + """BGP Address Family Identifier (AFI).""" + safi: Safi | None = None + """BGP Subsequent Address Family Identifier (SAFI). Required when `afi` is `ipv4` or `ipv6`.""" + vrf: str = "default" + """Optional VRF when `afi` is `ipv4` or `ipv6`. Defaults to `default`. + + If the input `afi` is NOT `ipv4` or `ipv6` (e.g. `evpn`, `vpn-ipv4`, etc.), the `vrf` must be `default`. + + These AFIs operate at a global level and do not use the VRF concept in the same way as IPv4/IPv6. + """ + num_peers: PositiveInt | None = None + """Number of expected established BGP peers with negotiated AFI/SAFI. Required field in the `VerifyBGPPeerCount` test.""" + peers: list[IPv4Address | IPv6Address] | None = None + """List of expected IPv4/IPv6 BGP peers supporting the AFI/SAFI. Required field in the `VerifyBGPSpecificPeers` test.""" + check_tcp_queues: bool = True + """Flag to check if the TCP session queues are empty for a BGP peer. Defaults to `True`. + + Can be disabled in the `VerifyBGPPeersHealth` and `VerifyBGPSpecificPeers` tests. + """ + check_peer_state: bool = False + """Flag to check if the peers are established with negotiated AFI/SAFI. Defaults to `False`. + + Can be enabled in the `VerifyBGPPeerCount` tests. + """ + + @model_validator(mode="after") + def validate_inputs(self) -> Self: + """Validate the inputs provided to the BgpAddressFamily class. + + If `afi` is either `ipv4` or `ipv6`, `safi` must be provided. + + If `afi` is not `ipv4` or `ipv6`, `safi` must NOT be provided and `vrf` must be `default`. + """ + if self.afi in ["ipv4", "ipv6"]: + if self.safi is None: + msg = "'safi' must be provided when afi is ipv4 or ipv6" + raise ValueError(msg) + elif self.safi is not None: + msg = "'safi' must not be provided when afi is not ipv4 or ipv6" + raise ValueError(msg) + elif self.vrf != "default": + msg = "'vrf' must be default when afi is not ipv4 or ipv6" + raise ValueError(msg) + return self + + @property + def eos_key(self) -> str: + """AFI/SAFI EOS key representation.""" + # Pydantic handles the validation of the AFI/SAFI combination, so we can ignore error handling here. + return AFI_SAFI_EOS_KEY[(self.afi, self.safi)] + + def __str__(self) -> str: + """Return a string representation of the BgpAddressFamily model. Used in failure messages. + + Examples + -------- + - AFI:ipv4 SAFI:unicast VRF:default + - AFI:evpn + """ + base_string = f"AFI: {self.afi}" + if self.safi is not None: + base_string += f" SAFI: {self.safi}" + if self.afi in ["ipv4", "ipv6"]: + base_string += f" VRF: {self.vrf}" + return base_string + + +class BgpAfi(BgpAddressFamily): # pragma: no cover + """Alias for the BgpAddressFamily model to maintain backward compatibility. + + When initialized, it will emit a deprecation warning and call the BgpAddressFamily model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the BgpAfi class, emitting a deprecation warning.""" + warn( + message="BgpAfi model is deprecated and will be removed in ANTA v2.0.0. Use the BgpAddressFamily model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/input_models/services.py b/anta/input_models/services.py new file mode 100644 index 000000000..596a3e38a --- /dev/null +++ b/anta/input_models/services.py @@ -0,0 +1,31 @@ +# 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. +"""Module containing input models for services tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address + +from pydantic import BaseModel, ConfigDict, Field + + +class DnsServer(BaseModel): + """Model for a DNS server configuration.""" + + model_config = ConfigDict(extra="forbid") + server_address: IPv4Address | IPv6Address + """The IPv4 or IPv6 address of the DNS server.""" + vrf: str = "default" + """The VRF instance in which the DNS server resides. Defaults to 'default'.""" + priority: int = Field(ge=0, le=4) + """The priority level of the DNS server, ranging from 0 to 4. Lower values indicate a higher priority, with 0 being the highest and 4 the lowest.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the DnsServer for reporting. + + Examples + -------- + Server 10.0.0.1 (VRF: default, Priority: 1) + """ + return f"Server {self.server_address} (VRF: {self.vrf}, Priority: {self.priority})" diff --git a/anta/input_models/system.py b/anta/input_models/system.py new file mode 100644 index 000000000..7600d2878 --- /dev/null +++ b/anta/input_models/system.py @@ -0,0 +1,31 @@ +# 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. +"""Module containing input models for system tests.""" + +from __future__ import annotations + +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict, Field + +from anta.custom_types import Hostname + + +class NTPServer(BaseModel): + """Model for a NTP server.""" + + model_config = ConfigDict(extra="forbid") + server_address: Hostname | IPv4Address + """The NTP server address as an IPv4 address or hostname. The NTP server name defined in the running configuration + of the device may change during DNS resolution, which is not handled in ANTA. Please provide the DNS-resolved server name. + For example, 'ntp.example.com' in the configuration might resolve to 'ntp3.example.com' in the device output.""" + preferred: bool = False + """Optional preferred for NTP server. If not provided, it defaults to `False`.""" + stratum: int = Field(ge=0, le=16) + """NTP stratum level (0 to 15) where 0 is the reference clock and 16 indicates unsynchronized. + Values should be between 0 and 15 for valid synchronization and 16 represents an out-of-sync state.""" + + def __str__(self) -> str: + """Representation of the NTPServer model.""" + return f"{self.server_address} (Preferred: {self.preferred}, Stratum: {self.stratum})" diff --git a/anta/models.py b/anta/models.py index b103a9965..4cebd997a 100644 --- a/anta/models.py +++ b/anta/models.py @@ -284,8 +284,7 @@ class AntaTest(ABC): The following is an example of an AntaTest subclass implementation: ```python class VerifyReachability(AntaTest): - name = "VerifyReachability" - description = "Test the network reachability to one or many destination IP(s)." + '''Test the network reachability to one or many destination IP(s).''' categories = ["connectivity"] commands = [AntaTemplate(template="ping vrf {vrf} {dst} source {src} repeat 2")] @@ -326,12 +325,17 @@ def test(self) -> None: Python logger for this test instance. """ - # Mandatory class attributes - # TODO: find a way to tell mypy these are mandatory for child classes - maybe Protocol + # Optional class attributes name: ClassVar[str] description: ClassVar[str] + + # Mandatory class attributes + # TODO: find a way to tell mypy these are mandatory for child classes + # follow this https://discuss.python.org/t/provide-a-canonical-way-to-declare-an-abstract-class-variable/69416 + # for now only enforced at runtime with __init_subclass__ categories: ClassVar[list[str]] commands: ClassVar[list[AntaTemplate | AntaCommand]] + # Class attributes to handle the progress bar of ANTA CLI progress: Progress | None = None nrfu_task: TaskID | None = None @@ -505,12 +509,19 @@ def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None: self.instance_commands[index].output = data def __init_subclass__(cls) -> None: - """Verify that the mandatory class attributes are defined.""" - mandatory_attributes = ["name", "description", "categories", "commands"] - for attr in mandatory_attributes: - if not hasattr(cls, attr): - msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}" - raise NotImplementedError(msg) + """Verify that the mandatory class attributes are defined and set name and description if not set.""" + mandatory_attributes = ["categories", "commands"] + if missing_attrs := [attr for attr in mandatory_attributes if not hasattr(cls, attr)]: + msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute(s): {', '.join(missing_attrs)}" + raise AttributeError(msg) + + cls.name = getattr(cls, "name", cls.__name__) + if not hasattr(cls, "description"): + if not cls.__doc__ or cls.__doc__.strip() == "": + # No doctsring or empty doctsring - raise + msg = f"Cannot set the description for class {cls.name}, either set it in the class definition or add a docstring to the class." + raise AttributeError(msg) + cls.description = cls.__doc__.split(sep="\n", maxsplit=1)[0] @property def module(self) -> str: diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py index 33c50a8f4..4554e6f60 100644 --- a/anta/reporter/csv_reporter.py +++ b/anta/reporter/csv_reporter.py @@ -58,8 +58,7 @@ def split_list_to_txt_list(cls, usr_list: list[str], delimiter: str = " - ") -> @classmethod def convert_to_list(cls, result: TestResult) -> list[str]: - """ - Convert a TestResult into a list of string for creating file content. + """Convert a TestResult into a list of string for creating file content. Parameters ---------- diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 055a3a179..81c4659cb 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -148,7 +148,7 @@ def _update_status(self, test_status: AntaTestStatus) -> None: if test_status == "error": self.error_status = True return - if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: + 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 = AntaTestStatus.FAILURE diff --git a/anta/runner.py b/anta/runner.py index 0147c3ccd..7b0eadf75 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -146,20 +146,26 @@ def prepare_tests( # Using a set to avoid inserting duplicate tests device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set) + total_test_count = 0 + # Create the device to tests mapping from the tags for device in inventory.devices: if tags: - if not any(tag in device.tags for tag in tags): + # If there are CLI tags, execute tests with matching tags for this device + if not (matching_tags := tags.intersection(device.tags)): # The device does not have any selected tag, skipping continue + device_to_tests[device].update(catalog.get_tests_by_tags(matching_tags)) else: # If there is no CLI tags, execute all tests that do not have any tags device_to_tests[device].update(catalog.tag_to_tests[None]) - # Add the tests with matching tags from device tags - device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) + # Then add the tests with matching tags from device tags + device_to_tests[device].update(catalog.get_tests_by_tags(device.tags)) + + total_test_count += len(device_to_tests[device]) - if len(device_to_tests.values()) == 0: + if total_test_count == 0: msg = ( f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs." ) diff --git a/anta/tests/aaa.py b/anta/tests/aaa.py index d6d0689e4..6328e06e6 100644 --- a/anta/tests/aaa.py +++ b/anta/tests/aaa.py @@ -35,8 +35,6 @@ class VerifyTacacsSourceIntf(AntaTest): ``` """ - name = "VerifyTacacsSourceIntf" - description = "Verifies TACACS source-interface for a specified VRF." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] @@ -81,8 +79,6 @@ class VerifyTacacsServers(AntaTest): ``` """ - name = "VerifyTacacsServers" - description = "Verifies TACACS servers are configured for a specified VRF." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] @@ -134,8 +130,6 @@ class VerifyTacacsServerGroups(AntaTest): ``` """ - name = "VerifyTacacsServerGroups" - description = "Verifies if the provided TACACS server group(s) are configured." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs", revision=1)] @@ -184,8 +178,6 @@ class VerifyAuthenMethods(AntaTest): ``` """ - name = "VerifyAuthenMethods" - description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication", revision=1)] @@ -245,8 +237,6 @@ class VerifyAuthzMethods(AntaTest): ``` """ - name = "VerifyAuthzMethods" - description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization", revision=1)] @@ -301,8 +291,6 @@ class VerifyAcctDefaultMethods(AntaTest): ``` """ - name = "VerifyAcctDefaultMethods" - description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] @@ -364,8 +352,6 @@ class VerifyAcctConsoleMethods(AntaTest): ``` """ - name = "VerifyAcctConsoleMethods" - description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)." categories: ClassVar[list[str]] = ["aaa"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting", revision=1)] diff --git a/anta/tests/avt.py b/anta/tests/avt.py index d72296aa0..66b30babe 100644 --- a/anta/tests/avt.py +++ b/anta/tests/avt.py @@ -18,8 +18,7 @@ class VerifyAVTPathHealth(AntaTest): - """ - Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs. + """Verifies the status of all Adaptive Virtual Topology (AVT) paths for all VRFs. Expected Results ---------------- @@ -34,7 +33,6 @@ class VerifyAVTPathHealth(AntaTest): ``` """ - name = "VerifyAVTPathHealth" description = "Verifies the status of all AVT paths for all VRFs." categories: ClassVar[list[str]] = ["avt"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")] @@ -73,8 +71,7 @@ def test(self) -> None: class VerifyAVTSpecificPath(AntaTest): - """ - Verifies the status and type of an Adaptive Virtual Topology (AVT) path for a specified VRF. + """Verifies the status and type of an Adaptive Virtual Topology (AVT) path for a specified VRF. Expected Results ---------------- @@ -97,7 +94,6 @@ class VerifyAVTSpecificPath(AntaTest): ``` """ - name = "VerifyAVTSpecificPath" description = "Verifies the status and type of an AVT path for a specified VRF." categories: ClassVar[list[str]] = ["avt"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ @@ -191,8 +187,7 @@ def test(self) -> None: class VerifyAVTRole(AntaTest): - """ - Verifies the Adaptive Virtual Topology (AVT) role of a device. + """Verifies the Adaptive Virtual Topology (AVT) role of a device. Expected Results ---------------- @@ -208,7 +203,6 @@ class VerifyAVTRole(AntaTest): ``` """ - name = "VerifyAVTRole" description = "Verifies the AVT role of a device." categories: ClassVar[list[str]] = ["avt"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show adaptive-virtual-topology path")] diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index f42d80de7..ba27f942b 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -8,12 +8,11 @@ from __future__ import annotations from datetime import datetime, timezone -from ipaddress import IPv4Address -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, ClassVar -from pydantic import BaseModel, Field +from pydantic import Field -from anta.custom_types import BfdInterval, BfdMultiplier, BfdProtocol +from anta.input_models.bfd import BFDPeer from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -22,12 +21,24 @@ class VerifyBFDSpecificPeers(AntaTest): - """Verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF. + """Verifies the state of IPv4 BFD peer sessions. + + This test performs the following checks for each specified peer: + + 1. Confirms that the specified VRF is configured. + 2. Verifies that the peer exists in the BFD configuration. + 3. For each specified BFD peer: + - Validates that the state is `up` + - Confirms that the remote discriminator identifier (disc) is non-zero. Expected Results ---------------- - * Success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF. - * Failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BFD configuration within the specified VRF. + - All BFD peers are `up` and remote disc is non-zero. + * Failure: If any of the following occur: + - A specified peer is not found in the BFD configuration within the specified VRF. + - Any BFD peer session is not `up` or the remote discriminator identifier is zero. Examples -------- @@ -42,8 +53,6 @@ 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=1)] @@ -51,20 +60,14 @@ class Input(AntaTest.Input): """Input model for the VerifyBFDSpecificPeers 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`.""" + """List of IPv4 BFD""" + BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer + """To maintain backward compatibility.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDSpecificPeers.""" - failures: dict[Any, Any] = {} + self.result.is_success() # Iterating over BFD peers for bfd_peer in self.inputs.bfd_peers: @@ -78,31 +81,33 @@ def test(self) -> None: # Check if BFD peer configured if not bfd_output: - failures[peer] = {vrf: "Not Configured"} + self.result.is_failure(f"{bfd_peer} - Not found") continue # Check BFD peer status and remote disc - if not (bfd_output.get("status") == "up" and bfd_output.get("remoteDisc") != 0): - failures[peer] = { - vrf: { - "status": bfd_output.get("status"), - "remote_disc": bfd_output.get("remoteDisc"), - } - } - - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Following BFD peers are not configured, status is not up or remote disc is zero:\n{failures}") + state = bfd_output.get("status") + remote_disc = bfd_output.get("remoteDisc") + if not (state == "up" and remote_disc != 0): + self.result.is_failure(f"{bfd_peer} - Session not properly established - State: {state} Remote Discriminator: {remote_disc}") class VerifyBFDPeersIntervals(AntaTest): - """Verifies the timers of the IPv4 BFD peers in the specified VRF. + """Verifies the timers of IPv4 BFD peer sessions. + + This test performs the following checks for each specified peer: + + 1. Confirms that the specified VRF is configured. + 2. Verifies that the peer exists in the BFD configuration. + 3. Confirms that BFD peer is correctly configured with the `Transmit interval, Receive interval and Multiplier`. Expected Results ---------------- - * Success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF. - * Failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF. + * Success: If all of the following conditions are met: + - All specified peers are found in the BFD configuration within the specified VRF. + - All BFD peers are correctly configured with the `Transmit interval, Receive interval and Multiplier`. + * Failure: If any of the following occur: + - A specified peer is not found in the BFD configuration within the specified VRF. + - Any BFD peer not correctly configured with the `Transmit interval, Receive interval and Multiplier`. Examples -------- @@ -123,8 +128,6 @@ 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=1)] @@ -132,34 +135,22 @@ class Input(AntaTest.Input): """Input model for the VerifyBFDPeersIntervals test.""" bfd_peers: list[BFDPeer] - """List of 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`.""" - tx_interval: BfdInterval - """Tx interval of BFD peer in milliseconds.""" - rx_interval: BfdInterval - """Rx interval of BFD peer in milliseconds.""" - multiplier: BfdMultiplier - """Multiplier of BFD peer.""" + """List of IPv4 BFD""" + BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer + """To maintain backward compatibility""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDPeersIntervals.""" - failures: dict[Any, Any] = {} + self.result.is_success() # Iterating over BFD peers - for bfd_peers in self.inputs.bfd_peers: - peer = str(bfd_peers.peer_address) - vrf = bfd_peers.vrf - tx_interval = bfd_peers.tx_interval - rx_interval = bfd_peers.rx_interval - multiplier = bfd_peers.multiplier + for bfd_peer in self.inputs.bfd_peers: + peer = str(bfd_peer.peer_address) + vrf = bfd_peer.vrf + tx_interval = bfd_peer.tx_interval + rx_interval = bfd_peer.rx_interval + multiplier = bfd_peer.multiplier # Check if BFD peer configured bfd_output = get_value( @@ -168,7 +159,7 @@ def test(self) -> None: separator="..", ) if not bfd_output: - failures[peer] = {vrf: "Not Configured"} + self.result.is_failure(f"{bfd_peer} - Not found") continue # Convert interval timer(s) into milliseconds to be consistent with the inputs. @@ -176,38 +167,34 @@ def test(self) -> None: 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": op_tx_interval, - "rx_interval": op_rx_interval, - "multiplier": detect_multiplier, - } - } + if op_tx_interval != tx_interval: + self.result.is_failure(f"{bfd_peer} - Incorrect Transmit interval - Expected: {tx_interval} Actual: {op_tx_interval}") - # Check if any failures - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Following BFD peers are not configured or timers are not correct:\n{failures}") + if op_rx_interval != rx_interval: + self.result.is_failure(f"{bfd_peer} - Incorrect Receive interval - Expected: {rx_interval} Actual: {op_rx_interval}") + + if detect_multiplier != multiplier: + self.result.is_failure(f"{bfd_peer} - Incorrect Multiplier - Expected: {multiplier} Actual: {detect_multiplier}") class VerifyBFDPeersHealth(AntaTest): """Verifies the health of IPv4 BFD peers across all VRFs. - It checks that no BFD peer is in the down state and that the discriminator value of the remote system is not zero. + This test performs the following checks for BFD peers across all VRFs: - Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours. + 1. Validates that the state is `up`. + 2. Confirms that the remote discriminator identifier (disc) is non-zero. + 3. Optionally verifies that the peer have not been down before a specified threshold of hours. Expected Results ---------------- - * Success: The test will pass if all IPv4 BFD peers are up, the discriminator value of each remote system is non-zero, - and the last downtime of each peer is above the defined threshold. - * Failure: The test will fail if any IPv4 BFD peer is down, the discriminator value of any remote system is zero, - or the last downtime of any peer is below the defined threshold. + * Success: If all of the following conditions are met: + - All BFD peers across the VRFs are up and remote disc is non-zero. + - Last downtime of each peer is above the defined threshold, if specified. + * Failure: If any of the following occur: + - Any BFD peer session is not up or the remote discriminator identifier is zero. + - Last downtime of any peer is below the defined threshold, if specified. Examples -------- @@ -218,8 +205,6 @@ class VerifyBFDPeersHealth(AntaTest): ``` """ - name = "VerifyBFDPeersHealth" - description = "Verifies the health of all IPv4 BFD peers." categories: ClassVar[list[str]] = ["bfd"] # revision 1 as later revision introduces additional nesting for type commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ @@ -236,18 +221,13 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDPeersHealth.""" - # Initialize failure strings - down_failures = [] - up_failures = [] + self.result.is_success() # Extract the current timestamp and command output clock_output = self.instance_commands[1].json_output current_timestamp = clock_output["utcTime"] bfd_output = self.instance_commands[0].json_output - # set the initial result - self.result.is_success() - # Check if any IPv4 BFD peer is configured ipv4_neighbors_exist = any(vrf_data["ipv4Neighbors"] for vrf_data in bfd_output["vrfs"].values()) if not ipv4_neighbors_exist: @@ -260,40 +240,40 @@ def test(self) -> None: for peer_data in neighbor_data["peerStats"].values(): peer_status = peer_data["status"] remote_disc = peer_data["remoteDisc"] - remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else "" last_down = peer_data["lastDown"] hours_difference = ( datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc) ).total_seconds() / 3600 - # Check if peer status is not up - if peer_status != "up": - down_failures.append(f"{peer} is {peer_status} in {vrf} VRF{remote_disc_info}.") + if not (peer_status == "up" and remote_disc != 0): + self.result.is_failure( + f"Peer: {peer} VRF: {vrf} - Session not properly established - State: {peer_status} Remote Discriminator: {remote_disc}" + ) # Check if the last down is within the threshold - elif self.inputs.down_threshold and hours_difference < self.inputs.down_threshold: - up_failures.append(f"{peer} in {vrf} VRF was down {round(hours_difference)} hours ago{remote_disc_info}.") + if self.inputs.down_threshold and hours_difference < self.inputs.down_threshold: + self.result.is_failure( + f"Peer: {peer} VRF: {vrf} - Session failure detected within the expected uptime threshold ({round(hours_difference)} hours ago)" + ) - # Check if remote disc is 0 - elif remote_disc == 0: - up_failures.append(f"{peer} in {vrf} VRF has remote disc {remote_disc}.") - # Check if there are any failures - if down_failures: - down_failures_str = "\n".join(down_failures) - self.result.is_failure(f"Following BFD peers are not up:\n{down_failures_str}") - 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 the registered routing protocol of IPv4 BFD peer sessions. + This test performs the following checks for each specified peer: -class VerifyBFDPeersRegProtocols(AntaTest): - """Verifies that IPv4 BFD peer(s) have the specified protocol(s) registered. + 1. Confirms that the specified VRF is configured. + 2. Verifies that the peer exists in the BFD configuration. + 3. Confirms that BFD peer is correctly configured with the `routing protocol`. 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). + * Success: If all of the following conditions are met: + - All specified peers are found in the BFD configuration within the specified VRF. + - All BFD peers are correctly configured with the `routing protocol`. + * Failure: If any of the following occur: + - A specified peer is not found in the BFD configuration within the specified VRF. + - Any BFD peer not correctly configured with the `routing protocol`. Examples -------- @@ -308,8 +288,6 @@ class VerifyBFDPeersRegProtocols(AntaTest): ``` """ - 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)] @@ -317,23 +295,14 @@ 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.""" + """List of IPv4 BFD""" + BFDPeer: ClassVar[type[BFDPeer]] = BFDPeer + """To maintain backward compatibility""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBFDPeersRegProtocols.""" - # Initialize failure messages - failures: dict[Any, Any] = {} + self.result.is_success() # Iterating over BFD peers, extract the parameters and command output for bfd_peer in self.inputs.bfd_peers: @@ -348,16 +317,11 @@ def test(self) -> None: # Check if BFD peer configured if not bfd_output: - failures[peer] = {vrf: "Not Configured"} + self.result.is_failure(f"{bfd_peer} - Not found") continue # Check registered protocols - difference = set(protocols) - set(get_value(bfd_output, "peerStatsDetail.apps")) - + difference = sorted(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}") + failures = " ".join(f"`{item}`" for item in difference) + self.result.is_failure(f"{bfd_peer} - {failures} routing protocol(s) not configured") diff --git a/anta/tests/configuration.py b/anta/tests/configuration.py index 4a1bd31d1..30d87a99c 100644 --- a/anta/tests/configuration.py +++ b/anta/tests/configuration.py @@ -33,8 +33,6 @@ class VerifyZeroTouch(AntaTest): ``` """ - name = "VerifyZeroTouch" - description = "Verifies ZeroTouch is disabled" categories: ClassVar[list[str]] = ["configuration"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch", revision=1)] @@ -64,8 +62,6 @@ class VerifyRunningConfigDiffs(AntaTest): ``` """ - name = "VerifyRunningConfigDiffs" - description = "Verifies there is no difference between the running-config and the startup-config" categories: ClassVar[list[str]] = ["configuration"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")] @@ -104,7 +100,6 @@ class VerifyRunningConfigLines(AntaTest): ``` """ - name = "VerifyRunningConfigLines" description = "Search the Running-Config for the given RegEx patterns." categories: ClassVar[list[str]] = ["configuration"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config", ofmt="text")] diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py index c0c6f731b..b26c770fd 100644 --- a/anta/tests/connectivity.py +++ b/anta/tests/connectivity.py @@ -7,12 +7,9 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address from typing import ClassVar -from pydantic import BaseModel - -from anta.custom_types import Interface +from anta.input_models.connectivity import Host, LLDPNeighbor, Neighbor from anta.models import AntaCommand, AntaTemplate, AntaTest @@ -43,11 +40,8 @@ class VerifyReachability(AntaTest): ``` """ - name = "VerifyReachability" - description = "Test the network reachability to one or many destination IP(s)." categories: ClassVar[list[str]] = ["connectivity"] - # 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 + # Template uses '{size}{df_bit}' without space since df_bit includes leading space when enabled commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="ping vrf {vrf} {destination} source {source} size {size}{df_bit} repeat {repeat}", revision=1) ] @@ -57,29 +51,14 @@ class Input(AntaTest.Input): hosts: list[Host] """List of host to ping.""" - - class Host(BaseModel): - """Model for a remote host to ping.""" - - destination: IPv4Address - """IPv4 address to ping.""" - source: IPv4Address | Interface - """IPv4 address source IP or egress interface to use.""" - vrf: str = "default" - """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.""" + Host: ClassVar[type[Host]] = Host + """To maintain backward compatibility.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: """Render the template for each host in the input list.""" 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 includes leading space when enabled, empty string when disabled 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) @@ -88,31 +67,28 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyReachability.""" - failures = [] + self.result.is_success() - for command in self.instance_commands: - src = command.params.source - dst = command.params.destination - repeat = command.params.repeat + for command, host in zip(self.instance_commands, self.inputs.hosts): + if f"{host.repeat} received" not in command.json_output["messages"][0]: + self.result.is_failure(f"{host} - Unreachable") - if f"{repeat} received" not in command.json_output["messages"][0]: - failures.append((str(src), str(dst))) - if not failures: - self.result.is_success() - else: - self.result.is_failure(f"Connectivity test failed for the following source-destination pairs: {failures}") +class VerifyLLDPNeighbors(AntaTest): + """Verifies the connection status of the specified LLDP (Link Layer Discovery Protocol) neighbors. + This test performs the following checks for each specified LLDP neighbor: -class VerifyLLDPNeighbors(AntaTest): - """Verifies that the provided LLDP neighbors are present and connected with the correct configuration. + 1. Confirming matching ports on both local and neighboring devices. + 2. Ensuring compatibility of device names and interface identifiers. + 3. Verifying neighbor configurations match expected values per interface; extra neighbors are ignored. Expected Results ---------------- - * Success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device. + * Success: The test will pass if all the provided LLDP neighbors are present and correctly connected to the specified port and device. * Failure: The test will fail if any of the following conditions are met: - - The provided LLDP neighbor is not found. - - The system name or port of the LLDP neighbor does not match the provided information. + - The provided LLDP neighbor is not found in the LLDP table. + - The system name or port of the LLDP neighbor does not match the expected information. Examples -------- @@ -129,60 +105,37 @@ class VerifyLLDPNeighbors(AntaTest): ``` """ - name = "VerifyLLDPNeighbors" - description = "Verifies that the provided LLDP neighbors are connected properly." categories: ClassVar[list[str]] = ["connectivity"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyLLDPNeighbors test.""" - neighbors: list[Neighbor] + neighbors: list[LLDPNeighbor] """List of LLDP neighbors.""" - - class Neighbor(BaseModel): - """Model for an LLDP neighbor.""" - - port: Interface - """LLDP port.""" - neighbor_device: str - """LLDP neighbor device.""" - neighbor_port: Interface - """LLDP neighbor port.""" + Neighbor: ClassVar[type[Neighbor]] = Neighbor + """To maintain backward compatibility.""" @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyLLDPNeighbors.""" - failures: dict[str, list[str]] = {} + self.result.is_success() output = self.instance_commands[0].json_output["lldpNeighbors"] - for neighbor in self.inputs.neighbors: if neighbor.port not in output: - failures.setdefault("Port(s) not configured", []).append(neighbor.port) + self.result.is_failure(f"{neighbor} - Port not found") continue if len(lldp_neighbor_info := output[neighbor.port]["lldpNeighborInfo"]) == 0: - failures.setdefault("No LLDP neighbor(s) on port(s)", []).append(neighbor.port) + self.result.is_failure(f"{neighbor} - No LLDP neighbors") continue - if not any( + # Check if the system name and neighbor port matches + match_found = any( info["systemName"] == neighbor.neighbor_device and info["neighborInterfaceInfo"]["interfaceId_v2"] == neighbor.neighbor_port for info in lldp_neighbor_info - ): - neighbors = "\n ".join( - [ - f"{neighbor[0]}_{neighbor[1]}" - for neighbor in [(info["systemName"], info["neighborInterfaceInfo"]["interfaceId_v2"]) for info in lldp_neighbor_info] - ] - ) - failures.setdefault("Wrong LLDP neighbor(s) on port(s)", []).append(f"{neighbor.port}\n {neighbors}") - - if not failures: - self.result.is_success() - else: - failure_messages = [] - for failure_type, ports in failures.items(): - ports_str = "\n ".join(ports) - failure_messages.append(f"{failure_type}:\n {ports_str}") - self.result.is_failure("\n".join(failure_messages)) + ) + if not match_found: + failure_msg = [f"{info['systemName']}/{info['neighborInterfaceInfo']['interfaceId_v2']}" for info in lldp_neighbor_info] + self.result.is_failure(f"{neighbor} - Wrong LLDP neighbors: {', '.join(failure_msg)}") diff --git a/anta/tests/cvx.py b/anta/tests/cvx.py new file mode 100644 index 000000000..63ec336a9 --- /dev/null +++ b/anta/tests/cvx.py @@ -0,0 +1,89 @@ +# 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. +"""Module related to the CVX tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from anta.models import AntaCommand, AntaTest + +if TYPE_CHECKING: + from anta.models import AntaTemplate + + +class VerifyMcsClientMounts(AntaTest): + """Verify if all MCS client mounts are in mountStateMountComplete. + + Expected Results + ---------------- + * Success: The test will pass if the MCS mount status on MCS Clients are mountStateMountComplete. + * Failure: The test will fail even if one switch's MCS client mount status is not mountStateMountComplete. + + Examples + -------- + ```yaml + anta.tests.cvx: + - VerifyMcsClientMounts: + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management cvx mounts", revision=1)] + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyMcsClientMounts.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + mount_states = command_output["mountStates"] + mcs_mount_state_detected = False + for mount_state in mount_states: + if not mount_state["type"].startswith("Mcs"): + continue + mcs_mount_state_detected = True + if (state := mount_state["state"]) != "mountStateMountComplete": + self.result.is_failure(f"MCS Client mount states are not valid: {state}") + + if not mcs_mount_state_detected: + self.result.is_failure("MCS Client mount states are not present") + + +class VerifyManagementCVX(AntaTest): + """Verifies the management CVX global status. + + Expected Results + ---------------- + * Success: The test will pass if the management CVX global status matches the expected status. + * Failure: The test will fail if the management CVX global status does not match the expected status. + + + Examples + -------- + ```yaml + anta.tests.cvx: + - VerifyManagementCVX: + enabled: true + ``` + """ + + categories: ClassVar[list[str]] = ["cvx"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management cvx", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifyManagementCVX test.""" + + enabled: bool + """Whether management CVX must be enabled (True) or disabled (False).""" + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifyManagementCVX.""" + command_output = self.instance_commands[0].json_output + self.result.is_success() + cluster_status = command_output["clusterStatus"] + if (cluster_state := cluster_status.get("enabled")) != self.inputs.enabled: + self.result.is_failure(f"Management CVX status is not valid: {cluster_state}") diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py index 6f98a2c9a..41e81a840 100644 --- a/anta/tests/field_notices.py +++ b/anta/tests/field_notices.py @@ -34,7 +34,6 @@ class VerifyFieldNotice44Resolution(AntaTest): ``` """ - name = "VerifyFieldNotice44Resolution" description = "Verifies that the device is using the correct Aboot version per FN0044." categories: ClassVar[list[str]] = ["field notices"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] @@ -110,15 +109,11 @@ def test(self) -> None: self.result.is_success() incorrect_aboot_version = ( - aboot_version.startswith("4.0.") - and int(aboot_version.split(".")[2]) < 7 - or aboot_version.startswith("4.1.") - and int(aboot_version.split(".")[2]) < 1 + (aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7) + or (aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1) or ( - aboot_version.startswith("6.0.") - and int(aboot_version.split(".")[2]) < 9 - or aboot_version.startswith("6.1.") - and int(aboot_version.split(".")[2]) < 7 + (aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9) + or (aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7) ) ) if incorrect_aboot_version: @@ -143,7 +138,6 @@ class VerifyFieldNotice72Resolution(AntaTest): ``` """ - name = "VerifyFieldNotice72Resolution" description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated." categories: ClassVar[list[str]] = ["field notices"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py index 676bdb4f0..c50a36799 100644 --- a/anta/tests/flow_tracking.py +++ b/anta/tests/flow_tracking.py @@ -17,8 +17,7 @@ def validate_record_export(record_export: dict[str, str], tracker_info: dict[str, str]) -> str: - """ - Validate the record export configuration against the tracker info. + """Validate the record export configuration against the tracker info. Parameters ---------- @@ -41,8 +40,7 @@ def validate_record_export(record_export: dict[str, str], tracker_info: dict[str def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, str]) -> str: - """ - Validate the exporter configurations against the tracker info. + """Validate the exporter configurations against the tracker info. Parameters ---------- @@ -74,8 +72,7 @@ def validate_exporters(exporters: list[dict[str, str]], tracker_info: dict[str, class VerifyHardwareFlowTrackerStatus(AntaTest): - """ - Verifies if hardware flow tracking is running and an input tracker is active. + """Verifies if hardware flow tracking is running and an input tracker is active. This test optionally verifies the tracker interval/timeout and exporter configuration. @@ -102,7 +99,6 @@ class VerifyHardwareFlowTrackerStatus(AntaTest): ``` """ - name = "VerifyHardwareFlowTrackerStatus" description = ( "Verifies if hardware flow tracking is running and an input tracker is active. Optionally verifies the tracker interval/timeout and exporter configuration." ) diff --git a/anta/tests/greent.py b/anta/tests/greent.py index b7632422b..97ac46df2 100644 --- a/anta/tests/greent.py +++ b/anta/tests/greent.py @@ -29,7 +29,6 @@ class VerifyGreenTCounters(AntaTest): ``` """ - name = "VerifyGreenTCounters" description = "Verifies if the GreenT counters are incremented." categories: ClassVar[list[str]] = ["greent"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters", revision=1)] @@ -61,8 +60,7 @@ class VerifyGreenT(AntaTest): ``` """ - name = "VerifyGreenT" - description = "Verifies if a GreenT policy is created." + description = "Verifies if a GreenT policy other than the default is created." categories: ClassVar[list[str]] = ["greent"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile", revision=1)] diff --git a/anta/tests/hardware.py b/anta/tests/hardware.py index 569c180d7..1c562b000 100644 --- a/anta/tests/hardware.py +++ b/anta/tests/hardware.py @@ -36,8 +36,6 @@ class VerifyTransceiversManufacturers(AntaTest): ``` """ - name = "VerifyTransceiversManufacturers" - description = "Verifies if all transceivers come from approved manufacturers." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", revision=2)] @@ -77,8 +75,6 @@ class VerifyTemperature(AntaTest): ``` """ - name = "VerifyTemperature" - description = "Verifies the device temperature." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)] @@ -110,8 +106,6 @@ class VerifyTransceiversTemperature(AntaTest): ``` """ - name = "VerifyTransceiversTemperature" - description = "Verifies the transceivers temperature." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", revision=1)] @@ -151,8 +145,6 @@ class VerifyEnvironmentSystemCooling(AntaTest): ``` """ - name = "VerifyEnvironmentSystemCooling" - description = "Verifies the system cooling status." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", revision=1)] @@ -232,8 +224,6 @@ class VerifyEnvironmentPower(AntaTest): ``` """ - name = "VerifyEnvironmentPower" - description = "Verifies the power supplies status." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", revision=1)] @@ -274,7 +264,6 @@ class VerifyAdverseDrops(AntaTest): ``` """ - name = "VerifyAdverseDrops" description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", revision=1)] diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py index 32b85d493..dc6938110 100644 --- a/anta/tests/interfaces.py +++ b/anta/tests/interfaces.py @@ -9,7 +9,7 @@ import re from ipaddress import IPv4Network -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar from pydantic import BaseModel, Field from pydantic_extra_types.mac_address import MacAddress @@ -17,6 +17,7 @@ from anta import GITHUB_SUGGESTION from anta.custom_types import EthernetInterface, Interface, Percent, PortChannelInterface, PositiveInteger from anta.decorators import skip_on_platforms +from anta.input_models.interfaces import InterfaceState from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools import custom_division, get_failed_logs, get_item, get_value @@ -44,8 +45,6 @@ class VerifyInterfaceUtilization(AntaTest): ``` """ - name = "VerifyInterfaceUtilization" - description = "Verifies that the utilization of interfaces is below a certain threshold." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show interfaces counters rates", revision=1), @@ -105,8 +104,6 @@ class VerifyInterfaceErrors(AntaTest): ``` """ - name = "VerifyInterfaceErrors" - description = "Verifies there are no interface error counters." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors", revision=1)] @@ -140,8 +137,6 @@ class VerifyInterfaceDiscards(AntaTest): ``` """ - name = "VerifyInterfaceDiscards" - description = "Verifies there are no interface discard counters." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards", revision=1)] @@ -174,8 +169,6 @@ class VerifyInterfaceErrDisabled(AntaTest): ``` """ - name = "VerifyInterfaceErrDisabled" - description = "Verifies there are no interfaces in the errdisabled state." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status", revision=1)] @@ -191,16 +184,20 @@ def test(self) -> None: class VerifyInterfacesStatus(AntaTest): - """Verifies if the provided list of interfaces are all in the expected state. + """Verifies the operational states of specified interfaces to ensure they match expected configurations. - - If line protocol status is provided, prioritize checking against both status and line protocol status - - If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up" - - If interface status is not "up", check only the interface status without considering line protocol status + This test performs the following checks for each specified interface: + + 1. If `line_protocol_status` is defined, both `status` and `line_protocol_status` are verified for the specified interface. + 2. If `line_protocol_status` is not provided but the `status` is "up", it is assumed that both the status and line protocol should be "up". + 3. If the interface `status` is not "up", only the interface's status is validated, with no line protocol check performed. Expected Results ---------------- - * Success: The test will pass if the provided interfaces are all in the expected state. - * Failure: The test will fail if any interface is not in the expected state. + * Success: If the interface status and line protocol status matches the expected operational state for all specified interfaces. + * Failure: If any of the following occur: + - The specified interface is not configured. + - The specified interface status and line protocol status does not match the expected operational state for any interface. Examples -------- @@ -219,8 +216,6 @@ class VerifyInterfacesStatus(AntaTest): ``` """ - name = "VerifyInterfacesStatus" - description = "Verifies the status of the provided interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)] @@ -229,30 +224,17 @@ class Input(AntaTest.Input): interfaces: list[InterfaceState] """List of interfaces with their expected state.""" - - class InterfaceState(BaseModel): - """Model for an interface state.""" - - name: Interface - """Interface to validate.""" - status: Literal["up", "down", "adminDown"] - """Expected status of the interface.""" - line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None - """Expected line protocol status of the interface.""" + InterfaceState: ClassVar[type[InterfaceState]] = InterfaceState @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyInterfacesStatus.""" - command_output = self.instance_commands[0].json_output - self.result.is_success() - intf_not_configured = [] - intf_wrong_state = [] - + command_output = self.instance_commands[0].json_output for interface in self.inputs.interfaces: if (intf_status := get_value(command_output["interfaceDescriptions"], interface.name, separator="..")) is None: - intf_not_configured.append(interface.name) + self.result.is_failure(f"{interface.name} - Not configured") continue status = "up" if intf_status["interfaceStatus"] in {"up", "connected"} else intf_status["interfaceStatus"] @@ -261,18 +243,15 @@ def test(self) -> None: # If line protocol status is provided, prioritize checking against both status and line protocol status if interface.line_protocol_status: if interface.status != status or interface.line_protocol_status != proto: - intf_wrong_state.append(f"{interface.name} is {status}/{proto}") + actual_state = f"Expected: {interface.status}/{interface.line_protocol_status}, Actual: {status}/{proto}" + self.result.is_failure(f"{interface.name} - {actual_state}") # If line protocol status is not provided and interface status is "up", expect both status and proto to be "up" # If interface status is not "up", check only the interface status without considering line protocol status - elif (interface.status == "up" and (status != "up" or proto != "up")) or (interface.status != status): - intf_wrong_state.append(f"{interface.name} is {status}/{proto}") - - if intf_not_configured: - self.result.is_failure(f"The following interface(s) are not configured: {intf_not_configured}") - - if intf_wrong_state: - self.result.is_failure(f"The following interface(s) are not in the expected state: {intf_wrong_state}") + elif interface.status == "up" and (status != "up" or proto != "up"): + self.result.is_failure(f"{interface.name} - Expected: up/up, Actual: {status}/{proto}") + elif interface.status != status: + self.result.is_failure(f"{interface.name} - Expected: {interface.status}, Actual: {status}") class VerifyStormControlDrops(AntaTest): @@ -291,8 +270,6 @@ class VerifyStormControlDrops(AntaTest): ``` """ - name = "VerifyStormControlDrops" - description = "Verifies there are no interface storm-control drop counters." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show storm-control", revision=1)] @@ -329,8 +306,6 @@ class VerifyPortChannels(AntaTest): ``` """ - name = "VerifyPortChannels" - description = "Verifies there are no inactive ports in all port channels." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel", revision=1)] @@ -364,8 +339,6 @@ class VerifyIllegalLACP(AntaTest): ``` """ - name = "VerifyIllegalLACP" - description = "Verifies there are no illegal LACP packets in all port channels." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports", revision=1)] @@ -401,7 +374,6 @@ class VerifyLoopbackCount(AntaTest): ``` """ - name = "VerifyLoopbackCount" description = "Verifies the number of loopback interfaces and their status." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)] @@ -450,8 +422,6 @@ class VerifySVI(AntaTest): ``` """ - name = "VerifySVI" - description = "Verifies the status of all SVIs." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief", revision=1)] @@ -495,7 +465,6 @@ class VerifyL3MTU(AntaTest): ``` """ - name = "VerifyL3MTU" description = "Verifies the global L3 MTU of all L3 interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] @@ -553,7 +522,6 @@ class VerifyIPProxyARP(AntaTest): ``` """ - name = "VerifyIPProxyARP" description = "Verifies if Proxy ARP is enabled." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}", revision=2)] @@ -607,7 +575,6 @@ class VerifyL2MTU(AntaTest): ``` """ - name = "VerifyL2MTU" description = "Verifies the global L2 MTU of all L2 interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] @@ -669,7 +636,6 @@ class VerifyInterfaceIPv4(AntaTest): ``` """ - name = "VerifyInterfaceIPv4" description = "Verifies the interface IPv4 addresses." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}", revision=2)] @@ -765,8 +731,6 @@ class VerifyIpVirtualRouterMac(AntaTest): ``` """ - name = "VerifyIpVirtualRouterMac" - description = "Verifies the IP virtual router MAC address." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router", revision=2)] @@ -818,8 +782,6 @@ class VerifyInterfacesSpeed(AntaTest): ``` """ - name = "VerifyInterfacesSpeed" - description = "Verifies the speed, lanes, auto-negotiation status, and mode as full duplex for interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")] @@ -909,8 +871,6 @@ class VerifyLACPInterfacesStatus(AntaTest): ``` """ - name = "VerifyLACPInterfacesStatus" - description = "Verifies the Link Aggregation Control Protocol(LACP) status of the provided interfaces." categories: ClassVar[list[str]] = ["interfaces"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show lacp interface {interface}", revision=1)] diff --git a/anta/tests/lanz.py b/anta/tests/lanz.py index dcdab69db..0995af7c9 100644 --- a/anta/tests/lanz.py +++ b/anta/tests/lanz.py @@ -30,7 +30,6 @@ class VerifyLANZ(AntaTest): ``` """ - name = "VerifyLANZ" description = "Verifies if LANZ is enabled." categories: ClassVar[list[str]] = ["lanz"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status", revision=1)] diff --git a/anta/tests/logging.py b/anta/tests/logging.py index 2972b4ec8..c391947c2 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -59,8 +59,6 @@ class VerifyLoggingPersistent(AntaTest): ``` """ - name = "VerifyLoggingPersistent" - description = "Verifies if logging persistent is enabled and logs are saved in flash." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show logging", ofmt="text"), @@ -100,8 +98,6 @@ class VerifyLoggingSourceIntf(AntaTest): ``` """ - name = "VerifyLoggingSourceIntf" - description = "Verifies logging source-interface for a specified VRF." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] @@ -144,8 +140,6 @@ class VerifyLoggingHosts(AntaTest): ``` """ - name = "VerifyLoggingHosts" - description = "Verifies logging hosts (syslog servers) for a specified VRF." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] @@ -176,10 +170,22 @@ def test(self) -> None: class VerifyLoggingLogsGeneration(AntaTest): """Verifies if logs are generated. + This test performs the following checks: + + 1. Sends a test log message at the **informational** level + 2. Retrieves the most recent logs (last 30 seconds) + 3. Verifies that the test message was successfully logged + + !!! warning + EOS logging buffer should be set to severity level `informational` or higher for this test to work. + Expected Results ---------------- - * Success: The test will pass if logs are generated. - * Failure: The test will fail if logs are NOT generated. + * Success: If logs are being generated and the test message is found in recent logs. + * Failure: If any of the following occur: + - The test message is not found in recent logs + - The logging system is not capturing new messages + - No logs are being generated Examples -------- @@ -189,8 +195,6 @@ class VerifyLoggingLogsGeneration(AntaTest): ``` """ - name = "VerifyLoggingLogsGeneration" - description = "Verifies if logs are generated." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation", ofmt="text"), @@ -213,10 +217,23 @@ def test(self) -> None: class VerifyLoggingHostname(AntaTest): """Verifies if logs are generated with the device FQDN. + This test performs the following checks: + + 1. Retrieves the device's configured FQDN + 2. Sends a test log message at the **informational** level + 3. Retrieves the most recent logs (last 30 seconds) + 4. Verifies that the test message includes the complete FQDN of the device + + !!! warning + EOS logging buffer should be set to severity level `informational` or higher for this test to work. + Expected Results ---------------- - * Success: The test will pass if logs are generated with the device FQDN. - * Failure: The test will fail if logs are NOT generated with the device FQDN. + * Success: If logs are generated with the device's complete FQDN. + * Failure: If any of the following occur: + - The test message is not found in recent logs + - The log message does not include the device's FQDN + - The FQDN in the log message doesn't match the configured FQDN Examples -------- @@ -226,8 +243,6 @@ class VerifyLoggingHostname(AntaTest): ``` """ - name = "VerifyLoggingHostname" - description = "Verifies if logs are generated with the device FQDN." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show hostname", revision=1), @@ -257,10 +272,24 @@ def test(self) -> None: class VerifyLoggingTimestamp(AntaTest): """Verifies if logs are generated with the appropriate timestamp. + This test performs the following checks: + + 1. Sends a test log message at the **informational** level + 2. Retrieves the most recent logs (last 30 seconds) + 3. Verifies that the test message is present with a high-resolution RFC3339 timestamp format + - Example format: `2024-01-25T15:30:45.123456+00:00` + - Includes microsecond precision + - Contains timezone offset + + !!! warning + EOS logging buffer should be set to severity level `informational` or higher for this test to work. + Expected Results ---------------- - * Success: The test will pass if logs are generated with the appropriate timestamp. - * Failure: The test will fail if logs are NOT generated with the appropriate timestamp. + * Success: If logs are generated with the correct high-resolution RFC3339 timestamp format. + * Failure: If any of the following occur: + - The test message is not found in recent logs + - The timestamp format does not match the expected RFC3339 format Examples -------- @@ -270,8 +299,6 @@ class VerifyLoggingTimestamp(AntaTest): ``` """ - name = "VerifyLoggingTimestamp" - description = "Verifies if logs are generated with the appropriate timestamp." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation", ofmt="text"), @@ -312,8 +339,6 @@ class VerifyLoggingAccounting(AntaTest): ``` """ - name = "VerifyLoggingAccounting" - description = "Verifies if AAA accounting logs are generated." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] @@ -344,8 +369,6 @@ class VerifyLoggingErrors(AntaTest): ``` """ - name = "VerifyLoggingErrors" - description = "Verifies there are no syslog messages with a severity of ERRORS or higher." categories: ClassVar[list[str]] = ["logging"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")] diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py index c894b98b6..e353420c9 100644 --- a/anta/tests/mlag.py +++ b/anta/tests/mlag.py @@ -36,8 +36,6 @@ class VerifyMlagStatus(AntaTest): ``` """ - name = "VerifyMlagStatus" - description = "Verifies the health status of the MLAG configuration." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] @@ -78,8 +76,6 @@ class VerifyMlagInterfaces(AntaTest): ``` """ - name = "VerifyMlagInterfaces" - description = "Verifies there are no inactive or active-partial MLAG ports." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] @@ -114,8 +110,6 @@ class VerifyMlagConfigSanity(AntaTest): ``` """ - name = "VerifyMlagConfigSanity" - description = "Verifies there are no MLAG config-sanity inconsistencies." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", revision=1)] @@ -153,8 +147,6 @@ class VerifyMlagReloadDelay(AntaTest): ``` """ - name = "VerifyMlagReloadDelay" - description = "Verifies the MLAG reload-delay parameters." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", revision=2)] @@ -203,7 +195,6 @@ class VerifyMlagDualPrimary(AntaTest): ``` """ - name = "VerifyMlagDualPrimary" description = "Verifies the MLAG dual-primary detection parameters." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] @@ -262,7 +253,6 @@ class VerifyMlagPrimaryPriority(AntaTest): ``` """ - name = "VerifyMlagPrimaryPriority" description = "Verifies the configuration of the MLAG primary priority." categories: ClassVar[list[str]] = ["mlag"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", revision=2)] diff --git a/anta/tests/multicast.py b/anta/tests/multicast.py index 554bd5759..f6e84babc 100644 --- a/anta/tests/multicast.py +++ b/anta/tests/multicast.py @@ -35,8 +35,6 @@ class VerifyIGMPSnoopingVlans(AntaTest): ``` """ - name = "VerifyIGMPSnoopingVlans" - description = "Verifies the IGMP snooping status for the provided VLANs." categories: ClassVar[list[str]] = ["multicast"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] @@ -78,8 +76,6 @@ class VerifyIGMPSnoopingGlobal(AntaTest): ``` """ - name = "VerifyIGMPSnoopingGlobal" - description = "Verifies the IGMP snooping global configuration." categories: ClassVar[list[str]] = ["multicast"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping", revision=1)] diff --git a/anta/tests/path_selection.py b/anta/tests/path_selection.py index 416cb8c4a..15b06aef2 100644 --- a/anta/tests/path_selection.py +++ b/anta/tests/path_selection.py @@ -18,8 +18,7 @@ class VerifyPathsHealth(AntaTest): - """ - Verifies the path and telemetry state of all paths under router path-selection. + """Verifies the path and telemetry state of all paths under router path-selection. The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry. @@ -38,8 +37,6 @@ class VerifyPathsHealth(AntaTest): ``` """ - name = "VerifyPathsHealth" - description = "Verifies the path and telemetry state of all paths under router path-selection." categories: ClassVar[list[str]] = ["path-selection"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show path-selection paths", revision=1)] @@ -73,8 +70,7 @@ def test(self) -> None: class VerifySpecificPath(AntaTest): - """ - Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection. + """Verifies the path and telemetry state of a specific path for an IPv4 peer under router path-selection. The expected states are 'IPsec established', 'Resolved' for path and 'active' for telemetry. @@ -98,8 +94,6 @@ class VerifySpecificPath(AntaTest): ``` """ - name = "VerifySpecificPath" - description = "Verifies the path and telemetry state of a specific path under router path-selection." categories: ClassVar[list[str]] = ["path-selection"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show path-selection paths peer {peer} path-group {group} source {source} destination {destination}", revision=1) diff --git a/anta/tests/profiles.py b/anta/tests/profiles.py index 859c8866c..93edacd59 100644 --- a/anta/tests/profiles.py +++ b/anta/tests/profiles.py @@ -33,7 +33,6 @@ class VerifyUnifiedForwardingTableMode(AntaTest): ``` """ - name = "VerifyUnifiedForwardingTableMode" description = "Verifies the device is using the expected UFT mode." categories: ClassVar[list[str]] = ["profiles"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", revision=1)] @@ -72,7 +71,6 @@ class VerifyTcamProfile(AntaTest): ``` """ - name = "VerifyTcamProfile" description = "Verifies the device TCAM profile." categories: ClassVar[list[str]] = ["profiles"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", revision=1)] diff --git a/anta/tests/ptp.py b/anta/tests/ptp.py index cbb8ee357..687f17517 100644 --- a/anta/tests/ptp.py +++ b/anta/tests/ptp.py @@ -33,7 +33,6 @@ class VerifyPtpModeStatus(AntaTest): ``` """ - name = "VerifyPtpModeStatus" description = "Verifies that the device is configured as a PTP Boundary Clock." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -80,7 +79,6 @@ class Input(AntaTest.Input): gmid: str """Identifier of the Grandmaster to which the device should be locked.""" - name = "VerifyPtpGMStatus" description = "Verifies that the device is locked to a valid PTP Grandmaster." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -120,7 +118,6 @@ class VerifyPtpLockStatus(AntaTest): ``` """ - name = "VerifyPtpLockStatus" description = "Verifies that the device was locked to the upstream PTP GM in the last minute." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] @@ -161,7 +158,6 @@ class VerifyPtpOffset(AntaTest): ``` """ - name = "VerifyPtpOffset" description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", revision=1)] @@ -206,7 +202,6 @@ class VerifyPtpPortModeStatus(AntaTest): ``` """ - name = "VerifyPtpPortModeStatus" description = "Verifies the PTP interfaces state." categories: ClassVar[list[str]] = ["ptp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", revision=2)] diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index a37328608..4f55a0f4b 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -7,16 +7,17 @@ # mypy: disable-error-code=attr-defined from __future__ import annotations -from ipaddress import IPv4Address, IPv4Network, IPv6Address +from ipaddress import IPv4Address, IPv4Network from typing import TYPE_CHECKING, Any, ClassVar -from pydantic import BaseModel, Field, PositiveInt, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from pydantic.v1.utils import deep_update from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Afi, BgpDropStats, BgpUpdateError, MultiProtocolCaps, Safi, Vni +from anta.custom_types import BgpDropStats, BgpUpdateError, MultiProtocolCaps, Vni +from anta.input_models.routing.bgp import BgpAddressFamily, BgpAfi from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_item, get_value +from anta.tools import format_data, get_item, get_value if TYPE_CHECKING: import sys @@ -27,95 +28,6 @@ from typing_extensions import Self -def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], afi: Afi, safi: Safi | None, vrf: str, issue: str | dict[str, Any]) -> None: - """Add a BGP failure entry to the given `failures` dictionary. - - Note: This function modifies `failures` in-place. - - Parameters - ---------- - failures - The dictionary to which the failure will be added. - afi - The address family identifier. - vrf - The VRF name. - safi - The subsequent address family identifier. - issue - A description of the issue. Can be of any type. - - Example - ------- - The `failures` dictionary will have the following structure: - ``` - { - ('afi1', 'safi1'): { - 'afi': 'afi1', - 'safi': 'safi1', - 'vrfs': { - 'vrf1': issue1, - 'vrf2': issue2 - } - }, - ('afi2', None): { - 'afi': 'afi2', - 'vrfs': { - 'vrf1': issue3 - } - } - } - ``` - - """ - key = (afi, safi) - - failure_entry = failures.setdefault(key, {"afi": afi, "safi": safi, "vrfs": {}}) if safi else failures.setdefault(key, {"afi": afi, "vrfs": {}}) - - failure_entry["vrfs"][vrf] = issue - - -def _check_peer_issues(peer_data: dict[str, Any] | None) -> dict[str, Any]: - """Check for issues in BGP peer data. - - Parameters - ---------- - peer_data - The BGP peer data dictionary nested in the `show bgp summary` command. - - Returns - ------- - dict - Dictionary with keys indicating issues or an empty dictionary if no issues. - - Raises - ------ - ValueError - If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. - - Example - ------- - This can for instance return - ``` - {"peerNotFound": True} - {"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0} - {} - ``` - - """ - if peer_data is None: - return {"peerNotFound": True} - - if any(key not in peer_data for key in ["peerState", "inMsgQueue", "outMsgQueue"]): - msg = "Provided BGP peer data is invalid." - raise ValueError(msg) - - if peer_data["peerState"] != "Established" or peer_data["inMsgQueue"] != 0 or peer_data["outMsgQueue"] != 0: - return {"peerState": peer_data["peerState"], "inMsgQueue": peer_data["inMsgQueue"], "outMsgQueue": peer_data["outMsgQueue"]} - - return {} - - def _add_bgp_routes_failure( bgp_routes: list[str], bgp_output: dict[str, Any], peer: str, vrf: str, route_type: str = "advertised_routes" ) -> dict[str, dict[str, dict[str, dict[str, list[str]]]]]: @@ -171,19 +83,44 @@ def _add_bgp_routes_failure( return failure_routes -class VerifyBGPPeerCount(AntaTest): - """Verifies the count of BGP peers for a given address family. +def _check_bgp_neighbor_capability(capability_status: dict[str, bool]) -> bool: + """Check if a BGP neighbor capability is advertised, received, and enabled. - It supports multiple types of Address Families Identifiers (AFI) and Subsequent Address Family Identifiers (SAFI). + Parameters + ---------- + capability_status + A dictionary containing the capability status. + + Returns + ------- + bool + True if the capability is advertised, received, and enabled, False otherwise. + + Example + ------- + >>> _check_bgp_neighbor_capability({"advertised": True, "received": True, "enabled": True}) + True + """ + return all(capability_status.get(state, False) for state in ("advertised", "received", "enabled")) + + +class VerifyBGPPeerCount(AntaTest): + """Verifies the count of BGP peers for given address families. - For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 (AFI) which is handled automatically in this test. + This test performs the following checks for each specified address family: - Please refer to the Input class attributes below for details. + 1. Confirms that the specified VRF is configured. + 2. Counts the number of peers that are: + - If `check_peer_state` is set to True, Counts the number of BGP peers that are in the `Established` state and + have successfully negotiated the specified AFI/SAFI + - If `check_peer_state` is set to False, skips validation of the `Established` state and AFI/SAFI negotiation. Expected Results ---------------- - * Success: If the count of BGP peers matches the expected count for each address family and VRF. - * Failure: If the count of BGP peers does not match the expected count, or if BGP is not configured for an expected VRF or address family. + * Success: If the count of BGP peers matches the expected count with `check_peer_state` enabled/disabled. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - The BGP peer count does not match expected value with `check_peer_state` enabled/disabled." Examples -------- @@ -209,130 +146,78 @@ class VerifyBGPPeerCount(AntaTest): ``` """ - name = "VerifyBGPPeerCount" - description = "Verifies the count of BGP peers." categories: ClassVar[list[str]] = ["bgp"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}", revision=3), - AntaTemplate(template="show bgp {afi} summary", revision=3), - ] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp summary vrf all", revision=1)] class Input(AntaTest.Input): """Input model for the VerifyBGPPeerCount test.""" - address_families: list[BgpAfi] - """List of BGP address families (BgpAfi).""" - - class BgpAfi(BaseModel): - """Model for a BGP address family (AFI) and subsequent address family (SAFI).""" - - afi: Afi - """BGP address family (AFI).""" - safi: Safi | None = None - """Optional BGP subsequent service family (SAFI). - - If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. - """ - vrf: str = "default" - """ - Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. - - If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. - """ - num_peers: PositiveInt - """Number of expected BGP peer(s).""" - - @model_validator(mode="after") - def validate_inputs(self) -> Self: - """Validate the inputs provided to the BgpAfi class. - - If afi is either ipv4 or ipv6, safi must be provided. - - If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. - """ - if self.afi in ["ipv4", "ipv6"]: - if self.safi is None: - msg = "'safi' must be provided when afi is ipv4 or ipv6" - raise ValueError(msg) - elif self.safi is not None: - msg = "'safi' must not be provided when afi is not ipv4 or ipv6" - raise ValueError(msg) - elif self.vrf != "default": - msg = "'vrf' must be default when afi is not ipv4 or ipv6" + address_families: list[BgpAddressFamily] + """List of BGP address families.""" + BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi + + @field_validator("address_families") + @classmethod + def validate_address_families(cls, address_families: list[BgpAddressFamily]) -> list[BgpAddressFamily]: + """Validate that 'num_peers' field is provided in each address family.""" + for af in address_families: + if af.num_peers is None: + msg = f"{af} 'num_peers' field missing in the input" raise ValueError(msg) - return self - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP address family in the input list.""" - commands = [] - for afi in self.inputs.address_families: - if template == VerifyBGPPeerCount.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi != "sr-te": - commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) - - # For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 - elif template == VerifyBGPPeerCount.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi == "sr-te": - commands.append(template.render(afi=afi.safi, safi=afi.afi, vrf=afi.vrf)) - elif template == VerifyBGPPeerCount.commands[1] and afi.afi not in ["ipv4", "ipv6"]: - commands.append(template.render(afi=afi.afi)) - return commands + return address_families @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeerCount.""" self.result.is_success() - failures: dict[tuple[str, Any], dict[str, Any]] = {} - - for command in self.instance_commands: - num_peers = None - peer_count = 0 - command_output = command.json_output - - afi = command.params.afi - safi = command.params.safi if hasattr(command.params, "safi") else None - afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default" - - # Swapping AFI and SAFI in case of SR-TE - if afi == "sr-te": - afi, safi = safi, afi + output = self.instance_commands[0].json_output - for input_entry in self.inputs.address_families: - if input_entry.afi == afi and input_entry.safi == safi and input_entry.vrf == afi_vrf: - num_peers = input_entry.num_peers - break - - if not (vrfs := command_output.get("vrfs")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + for address_family in self.inputs.address_families: + # Check if the VRF is configured + if (vrf_output := get_value(output, f"vrfs.{address_family.vrf}")) is None: + self.result.is_failure(f"{address_family} - VRF not configured") continue - if afi_vrf == "all": - for vrf_data in vrfs.values(): - peer_count += len(vrf_data["peers"]) + peers_data = vrf_output.get("peers", {}).values() + if not address_family.check_peer_state: + # Count the number of peers without considering the state and negotiated AFI/SAFI check if the count matches the expected count + peer_count = sum(1 for peer_data in peers_data if address_family.eos_key in peer_data) else: - peer_count += len(command_output["vrfs"][afi_vrf]["peers"]) + # Count the number of established peers with negotiated AFI/SAFI + peer_count = sum( + 1 + for peer_data in peers_data + if peer_data.get("peerState") == "Established" and get_value(peer_data, f"{address_family.eos_key}.afiSafiState") == "negotiated" + ) - if peer_count != num_peers: - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue=f"Expected: {num_peers}, Actual: {peer_count}") - - if failures: - self.result.is_failure(f"Failures: {list(failures.values())}") + # Check if the count matches the expected count + if address_family.num_peers != peer_count: + self.result.is_failure(f"{address_family} - Expected: {address_family.num_peers}, Actual: {peer_count}") class VerifyBGPPeersHealth(AntaTest): - """Verifies the health of BGP peers. - - It will validate that all BGP sessions are established and all message queues for these BGP sessions are empty for a given address family. + """Verifies the health of BGP peers for given address families. - It supports multiple types of Address Families Identifiers (AFI) and Subsequent Address Family Identifiers (SAFI). + This test performs the following checks for each specified address family: - For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 (AFI) which is handled automatically in this test. - - Please refer to the Input class attributes below for details. + 1. Validates that the VRF is configured. + 2. Checks if there are any peers for the given AFI/SAFI. + 3. For each relevant peer: + - Verifies that the BGP session is in the `Established` state. + - Confirms that the AFI/SAFI state is `negotiated`. + - Checks that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` to `False`. Expected Results ---------------- - * Success: If all BGP sessions are established and all messages queues are empty for each address family and VRF. - * Failure: If there are issues with any of the BGP sessions, or if BGP is not configured for an expected VRF or address family. + * Success: If all checks pass for all specified address families and their peers. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - No peers are found for a given AFI/SAFI. + - Any BGP session is not in the `Established` state. + - The AFI/SAFI state is not 'negotiated' for any peer. + - Any TCP message queue (input or output) is not empty when `check_tcp_queues` is `True` (default). Examples -------- @@ -348,130 +233,81 @@ class VerifyBGPPeersHealth(AntaTest): - afi: "ipv6" safi: "unicast" vrf: "DEV" + check_tcp_queues: false ``` """ - name = "VerifyBGPPeersHealth" - description = "Verifies the health of BGP peers" categories: ClassVar[list[str]] = ["bgp"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}", revision=3), - AntaTemplate(template="show bgp {afi} summary", revision=3), - ] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] class Input(AntaTest.Input): """Input model for the VerifyBGPPeersHealth test.""" - address_families: list[BgpAfi] - """List of BGP address families (BgpAfi).""" - - class BgpAfi(BaseModel): - """Model for a BGP address family (AFI) and subsequent address family (SAFI).""" - - afi: Afi - """BGP address family (AFI).""" - safi: Safi | None = None - """Optional BGP subsequent service family (SAFI). - - If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. - """ - vrf: str = "default" - """ - Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. - - If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. - """ - - @model_validator(mode="after") - def validate_inputs(self) -> Self: - """Validate the inputs provided to the BgpAfi class. - - If afi is either ipv4 or ipv6, safi must be provided. - - If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. - """ - if self.afi in ["ipv4", "ipv6"]: - if self.safi is None: - msg = "'safi' must be provided when afi is ipv4 or ipv6" - raise ValueError(msg) - elif self.safi is not None: - msg = "'safi' must not be provided when afi is not ipv4 or ipv6" - raise ValueError(msg) - elif self.vrf != "default": - msg = "'vrf' must be default when afi is not ipv4 or ipv6" - raise ValueError(msg) - return self - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP address family in the input list.""" - commands = [] - for afi in self.inputs.address_families: - if template == VerifyBGPPeersHealth.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi != "sr-te": - commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) - - # For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 - elif template == VerifyBGPPeersHealth.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi == "sr-te": - commands.append(template.render(afi=afi.safi, safi=afi.afi, vrf=afi.vrf)) - elif template == VerifyBGPPeersHealth.commands[1] and afi.afi not in ["ipv4", "ipv6"]: - commands.append(template.render(afi=afi.afi)) - return commands + address_families: list[BgpAddressFamily] + """List of BGP address families.""" + BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPPeersHealth.""" self.result.is_success() - failures: dict[tuple[str, Any], dict[str, Any]] = {} - - for command in self.instance_commands: - command_output = command.json_output - - afi = command.params.afi - safi = command.params.safi if hasattr(command.params, "safi") else None - afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default" - - # Swapping AFI and SAFI in case of SR-TE - if afi == "sr-te": - afi, safi = safi, afi + output = self.instance_commands[0].json_output - if not (vrfs := command_output.get("vrfs")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") + for address_family in self.inputs.address_families: + # Check if the VRF is configured + if (vrf_output := get_value(output, f"vrfs.{address_family.vrf}")) is None: + self.result.is_failure(f"{address_family} - VRF not configured") continue - for vrf, vrf_data in vrfs.items(): - if not (peers := vrf_data.get("peers")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="No Peers") - continue + # Check if any peers are found for this AFI/SAFI + relevant_peers = [ + peer for peer in vrf_output.get("peerList", []) if get_value(peer, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}") is not None + ] - peer_issues = {} - for peer, peer_data in peers.items(): - issues = _check_peer_issues(peer_data) + if not relevant_peers: + self.result.is_failure(f"{address_family} - No peers found") + continue - if issues: - peer_issues[peer] = issues + for peer in relevant_peers: + # Check if the BGP session is established + if peer["state"] != "Established": + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Session state is not established - State: {peer['state']}") - if peer_issues: - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=vrf, issue=peer_issues) + # Check if the AFI/SAFI state is negotiated + capability_status = get_value(peer, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}") + if not _check_bgp_neighbor_capability(capability_status): + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - AFI/SAFI state is not negotiated - {format_data(capability_status)}") - if failures: - self.result.is_failure(f"Failures: {list(failures.values())}") + # Check the TCP session message queues + inq = peer["peerTcpInfo"]["inputQueueLength"] + outq = peer["peerTcpInfo"]["outputQueueLength"] + if address_family.check_tcp_queues and (inq != 0 or outq != 0): + self.result.is_failure(f"{address_family} Peer: {peer['peerAddress']} - Session has non-empty message queues - InQ: {inq}, OutQ: {outq}") class VerifyBGPSpecificPeers(AntaTest): - """Verifies the health of specific BGP peer(s). - - It will validate that the BGP session is established and all message queues for this BGP session are empty for the given peer(s). + """Verifies the health of specific BGP peer(s) for given address families. - It supports multiple types of Address Families Identifiers (AFI) and Subsequent Address Family Identifiers (SAFI). + This test performs the following checks for each specified address family and peer: - For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 (AFI) which is handled automatically in this test. - - Please refer to the Input class attributes below for details. + 1. Confirms that the specified VRF is configured. + 2. For each specified peer: + - Verifies that the peer is found in the BGP configuration. + - Checks that the BGP session is in the `Established` state. + - Confirms that the AFI/SAFI state is `negotiated`. + - Ensures that both input and output TCP message queues are empty. + Can be disabled by setting `check_tcp_queues` to `False`. Expected Results ---------------- - * Success: If the BGP session is established and all messages queues are empty for each given peer. - * Failure: If the BGP session has issues or is not configured, or if BGP is not configured for an expected VRF or address family. + * Success: If all checks pass for all specified peers in all address families. + * Failure: If any of the following occur: + - The specified VRF is not configured. + - A specified peer is not found in the BGP configuration. + - The BGP session for a peer is not in the `Established` state. + - The AFI/SAFI state is not `negotiated` for a peer. + - Any TCP message queue (input or output) is not empty for a peer when `check_tcp_queues` is `True` (default). Examples -------- @@ -494,123 +330,68 @@ class VerifyBGPSpecificPeers(AntaTest): ``` """ - name = "VerifyBGPSpecificPeers" - description = "Verifies the health of specific BGP peer(s)." categories: ClassVar[list[str]] = ["bgp"] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ - AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}", revision=3), - AntaTemplate(template="show bgp {afi} summary", revision=3), - ] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] class Input(AntaTest.Input): """Input model for the VerifyBGPSpecificPeers test.""" - address_families: list[BgpAfi] - """List of BGP address families (BgpAfi).""" - - class BgpAfi(BaseModel): - """Model for a BGP address family (AFI) and subsequent address family (SAFI).""" - - afi: Afi - """BGP address family (AFI).""" - safi: Safi | None = None - """Optional BGP subsequent service family (SAFI). - - If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. - """ - vrf: str = "default" - """ - Optional VRF for IPv4 and IPv6. If not provided, it defaults to `default`. - - `all` is NOT supported. - - If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. - """ - peers: list[IPv4Address | IPv6Address] - """List of BGP IPv4 or IPv6 peer.""" - - @model_validator(mode="after") - def validate_inputs(self) -> Self: - """Validate the inputs provided to the BgpAfi class. - - If afi is either ipv4 or ipv6, safi must be provided and vrf must NOT be all. - - If afi is not ipv4 or ipv6, safi must not be provided and vrf must be default. - """ - if self.afi in ["ipv4", "ipv6"]: - if self.safi is None: - msg = "'safi' must be provided when afi is ipv4 or ipv6" - raise ValueError(msg) - if self.vrf == "all": - msg = "'all' is not supported in this test. Use VerifyBGPPeersHealth test instead." - raise ValueError(msg) - elif self.safi is not None: - msg = "'safi' must not be provided when afi is not ipv4 or ipv6" + address_families: list[BgpAddressFamily] + """List of BGP address families.""" + BgpAfi: ClassVar[type[BgpAfi]] = BgpAfi + + @field_validator("address_families") + @classmethod + def validate_address_families(cls, address_families: list[BgpAddressFamily]) -> list[BgpAddressFamily]: + """Validate that 'peers' field is provided in each address family.""" + for af in address_families: + if af.peers is None: + msg = f"{af} 'peers' field missing in the input" raise ValueError(msg) - elif self.vrf != "default": - msg = "'vrf' must be default when afi is not ipv4 or ipv6" - raise ValueError(msg) - return self - - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render the template for each BGP address family in the input list.""" - commands = [] - - for afi in self.inputs.address_families: - if template == VerifyBGPSpecificPeers.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi != "sr-te": - commands.append(template.render(afi=afi.afi, safi=afi.safi, vrf=afi.vrf)) - - # For SR-TE SAFI, the EOS command supports sr-te first then ipv4/ipv6 - elif template == VerifyBGPSpecificPeers.commands[0] and afi.afi in ["ipv4", "ipv6"] and afi.safi == "sr-te": - commands.append(template.render(afi=afi.safi, safi=afi.afi, vrf=afi.vrf)) - elif template == VerifyBGPSpecificPeers.commands[1] and afi.afi not in ["ipv4", "ipv6"]: - commands.append(template.render(afi=afi.afi)) - return commands + return address_families @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyBGPSpecificPeers.""" self.result.is_success() - failures: dict[tuple[str, Any], dict[str, Any]] = {} + output = self.instance_commands[0].json_output - for command in self.instance_commands: - command_output = command.json_output - - afi = command.params.afi - safi = command.params.safi if hasattr(command.params, "safi") else None - afi_vrf = command.params.vrf if hasattr(command.params, "vrf") else "default" + for address_family in self.inputs.address_families: + # Check if the VRF is configured + if (vrf_output := get_value(output, f"vrfs.{address_family.vrf}")) is None: + self.result.is_failure(f"{address_family} - VRF not configured") + continue - # Swapping AFI and SAFI in case of SR-TE - if afi == "sr-te": - afi, safi = safi, afi + for peer in address_family.peers: + peer_ip = str(peer) - for input_entry in self.inputs.address_families: - if input_entry.afi == afi and input_entry.safi == safi and input_entry.vrf == afi_vrf: - afi_peers = input_entry.peers - break + # Check if the peer is found + if (peer_data := get_item(vrf_output["peerList"], "peerAddress", peer_ip)) is None: + self.result.is_failure(f"{address_family} Peer: {peer_ip} - Not configured") + continue - if not (vrfs := command_output.get("vrfs")): - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue="Not Configured") - continue + # Check if the BGP session is established + if peer_data["state"] != "Established": + self.result.is_failure(f"{address_family} Peer: {peer_ip} - Session state is not established - State: {peer_data['state']}") - peer_issues = {} - for peer in afi_peers: - peer_ip = str(peer) - peer_data = get_value(dictionary=vrfs, key=f"{afi_vrf}_peers_{peer_ip}", separator="_") - issues = _check_peer_issues(peer_data) - if issues: - peer_issues[peer_ip] = issues + # Check if the AFI/SAFI state is negotiated + capability_status = get_value(peer_data, f"neighborCapabilities.multiprotocolCaps.{address_family.eos_key}") + if not capability_status: + self.result.is_failure(f"{address_family} Peer: {peer_ip} - AFI/SAFI state is not negotiated") - if peer_issues: - _add_bgp_failures(failures=failures, afi=afi, safi=safi, vrf=afi_vrf, issue=peer_issues) + if capability_status and not _check_bgp_neighbor_capability(capability_status): + self.result.is_failure(f"{address_family} Peer: {peer_ip} - AFI/SAFI state is not negotiated - {format_data(capability_status)}") - if failures: - self.result.is_failure(f"Failures: {list(failures.values())}") + # Check the TCP session message queues + inq = peer_data["peerTcpInfo"]["inputQueueLength"] + outq = peer_data["peerTcpInfo"]["outputQueueLength"] + if address_family.check_tcp_queues and (inq != 0 or outq != 0): + self.result.is_failure(f"{address_family} Peer: {peer_ip} - Session has non-empty message queues - InQ: {inq}, OutQ: {outq}") class VerifyBGPExchangedRoutes(AntaTest): - """Verifies if the BGP peers have correctly advertised and received routes. + """Verifies the advertised and received routes of BGP peers. The route type should be 'valid' and 'active' for a specified VRF. @@ -642,8 +423,6 @@ class VerifyBGPExchangedRoutes(AntaTest): ``` """ - name = "VerifyBGPExchangedRoutes" - description = "Verifies the advertised and received routes of BGP peers." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show bgp neighbors {peer} advertised-routes vrf {vrf}", revision=3), @@ -734,7 +513,6 @@ class VerifyBGPPeerMPCaps(AntaTest): ``` """ - name = "VerifyBGPPeerMPCaps" description = "Verifies the multiprotocol capabilities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -829,7 +607,6 @@ class VerifyBGPPeerASNCap(AntaTest): ``` """ - name = "VerifyBGPPeerASNCap" description = "Verifies the four octet asn capabilities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -907,7 +684,6 @@ class VerifyBGPPeerRouteRefreshCap(AntaTest): ``` """ - name = "VerifyBGPPeerRouteRefreshCap" description = "Verifies the route refresh capabilities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -987,7 +763,6 @@ class VerifyBGPPeerMD5Auth(AntaTest): ``` """ - name = "VerifyBGPPeerMD5Auth" description = "Verifies the MD5 authentication and state of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -1062,8 +837,6 @@ class VerifyEVPNType2Route(AntaTest): ``` """ - name = "VerifyEVPNType2Route" - description = "Verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp evpn route-type mac-ip {address} vni {vni}", revision=2)] @@ -1139,7 +912,6 @@ class VerifyBGPAdvCommunities(AntaTest): ``` """ - name = "VerifyBGPAdvCommunities" description = "Verifies the advertised communities of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -1216,7 +988,6 @@ class VerifyBGPTimers(AntaTest): ``` """ - name = "VerifyBGPTimers" description = "Verifies the timers of a BGP peer." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all", revision=3)] @@ -1295,8 +1066,6 @@ class VerifyBGPPeerDropStats(AntaTest): ``` """ - 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)] @@ -1384,8 +1153,6 @@ class VerifyBGPPeerUpdateErrors(AntaTest): ``` """ - 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)] @@ -1469,8 +1236,6 @@ class VerifyBgpRouteMaps(AntaTest): ``` """ - name = "VerifyBgpRouteMaps" - description = "Verifies BGP inbound and outbound route-maps of BGP IPv4 peer(s)." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] @@ -1567,8 +1332,6 @@ class VerifyBGPPeerRouteLimit(AntaTest): ``` """ - name = "VerifyBGPPeerRouteLimit" - description = "Verifies maximum routes and maximum routes warning limit for the provided BGP IPv4 peer(s)." categories: ClassVar[list[str]] = ["bgp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp neighbors {peer} vrf {vrf}", revision=3)] diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py index d1322a50d..fb9e3175d 100644 --- a/anta/tests/routing/generic.py +++ b/anta/tests/routing/generic.py @@ -26,7 +26,7 @@ class VerifyRoutingProtocolModel(AntaTest): - """Verifies the configured routing protocol model is the one we expect. + """Verifies the configured routing protocol model. Expected Results ---------------- @@ -43,8 +43,6 @@ class VerifyRoutingProtocolModel(AntaTest): ``` """ - name = "VerifyRoutingProtocolModel" - description = "Verifies the configured routing protocol model." categories: ClassVar[list[str]] = ["routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] @@ -85,8 +83,6 @@ class VerifyRoutingTableSize(AntaTest): ``` """ - name = "VerifyRoutingTableSize" - description = "Verifies the size of the IP routing table of the default VRF." categories: ClassVar[list[str]] = ["routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] @@ -138,8 +134,6 @@ 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), diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py index 684578ce1..f57c08275 100644 --- a/anta/tests/routing/isis.py +++ b/anta/tests/routing/isis.py @@ -158,8 +158,6 @@ class VerifyISISNeighborState(AntaTest): ``` """ - name = "VerifyISISNeighborState" - description = "Verifies all IS-IS neighbors are in UP state." categories: ClassVar[list[str]] = ["isis"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis neighbors", revision=1)] @@ -204,8 +202,6 @@ class VerifyISISNeighborCount(AntaTest): ``` """ - name = "VerifyISISNeighborCount" - description = "Verifies count of IS-IS interface per level" categories: ClassVar[list[str]] = ["isis"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)] @@ -277,7 +273,6 @@ class VerifyISISInterfaceMode(AntaTest): ``` """ - name = "VerifyISISInterfaceMode" description = "Verifies interface mode for IS-IS" categories: ClassVar[list[str]] = ["isis"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis interface brief", revision=1)] @@ -333,9 +328,7 @@ def test(self) -> None: class VerifyISISSegmentRoutingAdjacencySegments(AntaTest): - """Verifies ISIS Segment Routing Adjacency Segments. - - Verify that all expected Adjacency segments are correctly visible for each interface. + """Verify that all expected Adjacency segments are correctly visible for each interface. Expected Results ---------------- @@ -360,8 +353,6 @@ class VerifyISISSegmentRoutingAdjacencySegments(AntaTest): ``` """ - name = "VerifyISISSegmentRoutingAdjacencySegments" - description = "Verify expected Adjacency segments are correctly visible for each interface." categories: ClassVar[list[str]] = ["isis", "segment-routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing adjacency-segments", ofmt="json")] @@ -446,8 +437,7 @@ def test(self) -> None: class VerifyISISSegmentRoutingDataplane(AntaTest): - """ - Verify dataplane of a list of ISIS-SR instances. + """Verify dataplane of a list of ISIS-SR instances. Expected Results ---------------- @@ -468,8 +458,6 @@ class VerifyISISSegmentRoutingDataplane(AntaTest): ``` """ - name = "VerifyISISSegmentRoutingDataplane" - description = "Verify dataplane of a list of ISIS-SR instances" categories: ClassVar[list[str]] = ["isis", "segment-routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing", ofmt="json")] @@ -530,8 +518,7 @@ def test(self) -> None: class VerifyISISSegmentRoutingTunnels(AntaTest): - """ - Verify ISIS-SR tunnels computed by device. + """Verify ISIS-SR tunnels computed by device. Expected Results ---------------- @@ -561,8 +548,6 @@ class VerifyISISSegmentRoutingTunnels(AntaTest): ``` """ - name = "VerifyISISSegmentRoutingTunnels" - description = "Verify ISIS-SR tunnels computed by device" categories: ClassVar[list[str]] = ["isis", "segment-routing"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show isis segment-routing tunnel", ofmt="json")] @@ -638,8 +623,7 @@ def test(self) -> None: self.result.is_failure("\n".join(failure_message)) def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """ - Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`. + """Check if the tunnel type specified in `via_input` matches any of the tunnel types in `eos_entry`. Parameters ---------- @@ -666,8 +650,7 @@ def _check_tunnel_type(self, via_input: VerifyISISSegmentRoutingTunnels.Input.En return True def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """ - Check if the tunnel nexthop matches the given input. + """Check if the tunnel nexthop matches the given input. Parameters ---------- @@ -694,8 +677,7 @@ def _check_tunnel_nexthop(self, via_input: VerifyISISSegmentRoutingTunnels.Input return True def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """ - Check if the tunnel interface exists in the given EOS entry. + """Check if the tunnel interface exists in the given EOS entry. Parameters ---------- @@ -722,8 +704,7 @@ def _check_tunnel_interface(self, via_input: VerifyISISSegmentRoutingTunnels.Inp return True def _check_tunnel_id(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_entry: dict[str, Any]) -> bool: - """ - Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias. + """Check if the tunnel ID matches any of the tunnel IDs in the EOS entry's vias. Parameters ---------- diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py index 3ffd81d53..d5d12e29e 100644 --- a/anta/tests/routing/ospf.py +++ b/anta/tests/routing/ospf.py @@ -109,8 +109,6 @@ class VerifyOSPFNeighborState(AntaTest): ``` """ - name = "VerifyOSPFNeighborState" - description = "Verifies all OSPF neighbors are in FULL state." categories: ClassVar[list[str]] = ["ospf"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] @@ -146,8 +144,6 @@ class VerifyOSPFNeighborCount(AntaTest): ``` """ - name = "VerifyOSPFNeighborCount" - description = "Verifies the number of OSPF neighbors in FULL state is the one we expect." categories: ClassVar[list[str]] = ["ospf"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor", revision=1)] @@ -190,7 +186,6 @@ class VerifyOSPFMaxLSA(AntaTest): ``` """ - name = "VerifyOSPFMaxLSA" description = "Verifies all OSPF instances did not cross the maximum LSA threshold." categories: ClassVar[list[str]] = ["ospf"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf", revision=1)] diff --git a/anta/tests/security.py b/anta/tests/security.py index 71c9f12ee..13a48e577 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -42,8 +42,6 @@ class VerifySSHStatus(AntaTest): ``` """ - name = "VerifySSHStatus" - description = "Verifies if the SSHD agent is disabled in the default VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")] @@ -83,7 +81,6 @@ class VerifySSHIPv4Acl(AntaTest): ``` """ - name = "VerifySSHIPv4Acl" description = "Verifies if the SSHD agent has IPv4 ACL(s) configured." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary", revision=1)] @@ -132,7 +129,6 @@ class VerifySSHIPv6Acl(AntaTest): ``` """ - name = "VerifySSHIPv6Acl" description = "Verifies if the SSHD agent has IPv6 ACL(s) configured." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary", revision=1)] @@ -179,8 +175,6 @@ class VerifyTelnetStatus(AntaTest): ``` """ - name = "VerifyTelnetStatus" - description = "Verifies if Telnet is disabled in the default VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet", revision=1)] @@ -210,8 +204,6 @@ class VerifyAPIHttpStatus(AntaTest): ``` """ - name = "VerifyAPIHttpStatus" - description = "Verifies if eAPI HTTP server is disabled globally." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] @@ -242,7 +234,6 @@ class VerifyAPIHttpsSSL(AntaTest): ``` """ - name = "VerifyAPIHttpsSSL" description = "Verifies if the eAPI has a valid SSL profile." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands", revision=1)] @@ -285,8 +276,6 @@ class VerifyAPIIPv4Acl(AntaTest): ``` """ - name = "VerifyAPIIPv4Acl" - description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary", revision=1)] @@ -335,8 +324,6 @@ class VerifyAPIIPv6Acl(AntaTest): ``` """ - name = "VerifyAPIIPv6Acl" - description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary", revision=1)] @@ -395,8 +382,6 @@ class VerifyAPISSLCertificate(AntaTest): ``` """ - name = "VerifyAPISSLCertificate" - description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show management security ssl certificate", revision=1), @@ -505,8 +490,6 @@ class VerifyBannerLogin(AntaTest): ``` """ - name = "VerifyBannerLogin" - description = "Verifies the login banner of a device." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login", revision=1)] @@ -549,8 +532,6 @@ class VerifyBannerMotd(AntaTest): ``` """ - name = "VerifyBannerMotd" - description = "Verifies the motd banner of a device." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd", revision=1)] @@ -604,8 +585,6 @@ class VerifyIPv4ACL(AntaTest): ``` """ - name = "VerifyIPv4ACL" - description = "Verifies the configuration of IPv4 ACLs." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)] @@ -669,8 +648,7 @@ def test(self) -> None: class VerifyIPSecConnHealth(AntaTest): - """ - Verifies all IPv4 security connections. + """Verifies all IPv4 security connections. Expected Results ---------------- @@ -685,8 +663,6 @@ class VerifyIPSecConnHealth(AntaTest): ``` """ - name = "VerifyIPSecConnHealth" - description = "Verifies all IPv4 security connections." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip security connection vrf all")] @@ -716,8 +692,7 @@ def test(self) -> None: class VerifySpecificIPSecConn(AntaTest): - """ - Verifies the state of IPv4 security connections for a specified peer. + """Verifies the state of IPv4 security connections for a specified peer. It optionally allows for the verification of a specific path for a peer by providing source and destination addresses. If these addresses are not provided, it will verify all paths for the specified peer. @@ -744,7 +719,6 @@ class VerifySpecificIPSecConn(AntaTest): ``` """ - name = "VerifySpecificIPSecConn" description = "Verifies IPv4 security connections for a peer." categories: ClassVar[list[str]] = ["security"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip security connection vrf {vrf} path peer {peer}")] @@ -831,8 +805,7 @@ def test(self) -> None: class VerifyHardwareEntropy(AntaTest): - """ - Verifies hardware entropy generation is enabled on device. + """Verifies hardware entropy generation is enabled on device. Expected Results ---------------- @@ -847,8 +820,6 @@ class VerifyHardwareEntropy(AntaTest): ``` """ - 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")] diff --git a/anta/tests/services.py b/anta/tests/services.py index 618426350..dab1b3a56 100644 --- a/anta/tests/services.py +++ b/anta/tests/services.py @@ -7,14 +7,14 @@ # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined -from ipaddress import IPv4Address, IPv6Address from typing import ClassVar -from pydantic import BaseModel, Field +from pydantic import BaseModel from anta.custom_types import ErrDisableInterval, ErrDisableReasons +from anta.input_models.services import DnsServer from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_dict_superset, get_failed_logs, get_item +from anta.tools import get_dict_superset, get_failed_logs class VerifyHostname(AntaTest): @@ -34,8 +34,6 @@ class VerifyHostname(AntaTest): ``` """ - name = "VerifyHostname" - description = "Verifies the hostname of a device." categories: ClassVar[list[str]] = ["services"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname", revision=1)] @@ -77,7 +75,6 @@ class VerifyDNSLookup(AntaTest): ``` """ - name = "VerifyDNSLookup" description = "Verifies the DNS name to IP address resolution." categories: ClassVar[list[str]] = ["services"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}", revision=1)] @@ -109,10 +106,17 @@ def test(self) -> None: class VerifyDNSServers(AntaTest): """Verifies if the DNS (Domain Name Service) servers are correctly configured. + This test performs the following checks for each specified DNS Server: + + 1. Confirming correctly registered with a valid IPv4 or IPv6 address with the designated VRF. + 2. Ensuring an appropriate priority level. + Expected Results ---------------- * Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority. - * Failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input. + * Failure: The test will fail if any of the following conditions are met: + - The provided DNS server is not configured. + - The provided DNS server with designated VRF and priority does not match the expected information. Examples -------- @@ -129,8 +133,6 @@ class VerifyDNSServers(AntaTest): ``` """ - name = "VerifyDNSServers" - description = "Verifies if the DNS servers are correctly configured." categories: ClassVar[list[str]] = ["services"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server", revision=1)] @@ -139,38 +141,28 @@ class Input(AntaTest.Input): dns_servers: list[DnsServer] """List of DNS servers to verify.""" - - class DnsServer(BaseModel): - """Model for a DNS server.""" - - server_address: IPv4Address | IPv6Address - """The IPv4/IPv6 address of the DNS server.""" - vrf: str = "default" - """The VRF for the DNS server. Defaults to 'default' if not provided.""" - priority: int = Field(ge=0, le=4) - """The priority of the DNS server from 0 to 4, lower is first.""" + DnsServer: ClassVar[type[DnsServer]] = DnsServer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyDNSServers.""" - command_output = self.instance_commands[0].json_output["nameServerConfigs"] self.result.is_success() + + command_output = self.instance_commands[0].json_output["nameServerConfigs"] for server in self.inputs.dns_servers: address = str(server.server_address) vrf = server.vrf priority = server.priority input_dict = {"ipAddr": address, "vrf": vrf} - if get_item(command_output, "ipAddr", address) is None: - self.result.is_failure(f"DNS server `{address}` is not configured with any VRF.") - continue - + # Check if the DNS server is configured with specified VRF. if (output := get_dict_superset(command_output, input_dict)) is None: - self.result.is_failure(f"DNS server `{address}` is not configured with VRF `{vrf}`.") + self.result.is_failure(f"{server} - Not configured") continue + # Check if the DNS server priority matches with expected. if output["priority"] != priority: - self.result.is_failure(f"For DNS server `{address}`, the expected priority is `{priority}`, but `{output['priority']}` was found instead.") + self.result.is_failure(f"{server} - Incorrect priority - Priority: {output['priority']}") class VerifyErrdisableRecovery(AntaTest): @@ -194,8 +186,6 @@ class VerifyErrdisableRecovery(AntaTest): ``` """ - name = "VerifyErrdisableRecovery" - description = "Verifies the errdisable recovery reason, status, and interval." categories: ClassVar[list[str]] = ["services"] # NOTE: Only `text` output format is supported for this command commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")] diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index 5a01daa8e..a694f18fe 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -37,7 +37,6 @@ class VerifySnmpStatus(AntaTest): ``` """ - name = "VerifySnmpStatus" description = "Verifies if the SNMP agent is enabled." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -76,7 +75,6 @@ class VerifySnmpIPv4Acl(AntaTest): ``` """ - name = "VerifySnmpIPv4Acl" description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary", revision=1)] @@ -125,7 +123,6 @@ class VerifySnmpIPv6Acl(AntaTest): ``` """ - name = "VerifySnmpIPv6Acl" description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary", revision=1)] @@ -173,8 +170,6 @@ class VerifySnmpLocation(AntaTest): ``` """ - name = "VerifySnmpLocation" - description = "Verifies the SNMP location of a device." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -216,8 +211,6 @@ class VerifySnmpContact(AntaTest): ``` """ - name = "VerifySnmpContact" - description = "Verifies the SNMP contact of a device." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -264,8 +257,6 @@ class VerifySnmpPDUCounters(AntaTest): ``` """ - name = "VerifySnmpPDUCounters" - description = "Verifies the SNMP PDU counters." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] @@ -320,8 +311,6 @@ class VerifySnmpErrorCounters(AntaTest): - inBadCommunityNames """ - name = "VerifySnmpErrorCounters" - description = "Verifies the SNMP error counters." categories: ClassVar[list[str]] = ["snmp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)] diff --git a/anta/tests/software.py b/anta/tests/software.py index 4028dd963..9a41881ab 100644 --- a/anta/tests/software.py +++ b/anta/tests/software.py @@ -34,7 +34,6 @@ class VerifyEOSVersion(AntaTest): ``` """ - name = "VerifyEOSVersion" description = "Verifies the EOS version of the device." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] @@ -74,7 +73,6 @@ class VerifyTerminAttrVersion(AntaTest): ``` """ - name = "VerifyTerminAttrVersion" description = "Verifies the TerminAttr version of the device." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail", revision=1)] @@ -112,8 +110,6 @@ class VerifyEOSExtensions(AntaTest): ``` """ - name = "VerifyEOSExtensions" - description = "Verifies that all EOS extensions installed on the device are enabled for boot persistence." categories: ClassVar[list[str]] = ["software"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show extensions", revision=2), diff --git a/anta/tests/stp.py b/anta/tests/stp.py index 3208f0c40..93a0d2e39 100644 --- a/anta/tests/stp.py +++ b/anta/tests/stp.py @@ -36,8 +36,6 @@ class VerifySTPMode(AntaTest): ``` """ - name = "VerifySTPMode" - description = "Verifies the configured STP mode for a provided list of VLAN(s)." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}", revision=1)] @@ -93,8 +91,6 @@ class VerifySTPBlockedPorts(AntaTest): ``` """ - name = "VerifySTPBlockedPorts" - description = "Verifies there is no STP blocked ports." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports", revision=1)] @@ -126,8 +122,6 @@ class VerifySTPCounters(AntaTest): ``` """ - name = "VerifySTPCounters" - description = "Verifies there is no errors in STP BPDU packets." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters", revision=1)] @@ -163,7 +157,6 @@ class VerifySTPForwardingPorts(AntaTest): ``` """ - name = "VerifySTPForwardingPorts" description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status", revision=1)] @@ -222,8 +215,6 @@ class VerifySTPRootPriority(AntaTest): ``` """ - name = "VerifySTPRootPriority" - description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail", revision=1)] @@ -279,8 +270,6 @@ class VerifyStpTopologyChanges(AntaTest): ``` """ - name = "VerifyStpTopologyChanges" - description = "Verifies the number of changes across all interfaces in the Spanning Tree Protocol (STP) topology is below a threshold." categories: ClassVar[list[str]] = ["stp"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree topology status detail", revision=1)] diff --git a/anta/tests/stun.py b/anta/tests/stun.py index f06b5a0ab..8b4f4fb2f 100644 --- a/anta/tests/stun.py +++ b/anta/tests/stun.py @@ -18,10 +18,7 @@ class VerifyStunClient(AntaTest): - """ - Verifies the configuration of the STUN client, specifically the IPv4 source address and port. - - Optionally, it can also verify the public address and port. + """Verifies STUN client settings, including local IP/port and optionally public IP/port. Expected Results ---------------- @@ -45,8 +42,6 @@ class VerifyStunClient(AntaTest): ``` """ - name = "VerifyStunClient" - description = "Verifies the STUN client is configured with the specified IPv4 source address and port. Validate the public IP and port if provided." categories: ClassVar[list[str]] = ["stun"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show stun client translations {source_address} {source_port}")] @@ -118,8 +113,7 @@ def test(self) -> None: class VerifyStunServer(AntaTest): - """ - Verifies the STUN server status is enabled and running. + """Verifies the STUN server status is enabled and running. Expected Results ---------------- @@ -134,8 +128,6 @@ class VerifyStunServer(AntaTest): ``` """ - name = "VerifyStunServer" - description = "Verifies the STUN server status is enabled and running." categories: ClassVar[list[str]] = ["stun"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show stun server status", revision=1)] diff --git a/anta/tests/system.py b/anta/tests/system.py index d620d533b..6bed495e4 100644 --- a/anta/tests/system.py +++ b/anta/tests/system.py @@ -8,14 +8,12 @@ from __future__ import annotations import re -from ipaddress import IPv4Address from typing import TYPE_CHECKING, ClassVar -from pydantic import BaseModel, Field - -from anta.custom_types import Hostname, PositiveInteger +from anta.custom_types import PositiveInteger +from anta.input_models.system import NTPServer from anta.models import AntaCommand, AntaTest -from anta.tools import get_failed_logs, get_value +from anta.tools import get_value if TYPE_CHECKING: from anta.models import AntaTemplate @@ -42,7 +40,6 @@ class VerifyUptime(AntaTest): ``` """ - name = "VerifyUptime" description = "Verifies the device uptime." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime", revision=1)] @@ -80,8 +77,6 @@ class VerifyReloadCause(AntaTest): ``` """ - name = "VerifyReloadCause" - description = "Verifies the last reload cause of the device." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause", revision=1)] @@ -124,7 +119,6 @@ class VerifyCoredump(AntaTest): ``` """ - name = "VerifyCoredump" description = "Verifies there are no core dump files." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", revision=1)] @@ -143,7 +137,7 @@ def test(self) -> None: class VerifyAgentLogs(AntaTest): - """Verifies that no agent crash reports are present on the device. + """Verifies there are no agent crash reports. Expected Results ---------------- @@ -158,8 +152,6 @@ class VerifyAgentLogs(AntaTest): ``` """ - name = "VerifyAgentLogs" - description = "Verifies there are no agent crash reports." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")] @@ -191,8 +183,6 @@ class VerifyCPUUtilization(AntaTest): ``` """ - name = "VerifyCPUUtilization" - description = "Verifies whether the CPU utilization is below 75%." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once", revision=1)] @@ -223,8 +213,6 @@ class VerifyMemoryUtilization(AntaTest): ``` """ - name = "VerifyMemoryUtilization" - description = "Verifies whether the memory utilization is below 75%." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", revision=1)] @@ -255,8 +243,6 @@ class VerifyFileSystemUtilization(AntaTest): ``` """ - name = "VerifyFileSystemUtilization" - description = "Verifies that no partition is utilizing more than 75% of its disk space." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")] @@ -286,7 +272,6 @@ class VerifyNTP(AntaTest): ``` """ - name = "VerifyNTP" description = "Verifies if NTP is synchronised." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")] @@ -328,8 +313,6 @@ class VerifyNTPAssociations(AntaTest): ``` """ - name = "VerifyNTPAssociations" - description = "Verifies the Network Time Protocol (NTP) associations." categories: ClassVar[list[str]] = ["system"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp associations")] @@ -338,55 +321,33 @@ class Input(AntaTest.Input): ntp_servers: list[NTPServer] """List of NTP servers.""" - - class NTPServer(BaseModel): - """Model for a NTP server.""" - - server_address: Hostname | IPv4Address - """The NTP server address as an IPv4 address or hostname. The NTP server name defined in the running configuration - of the device may change during DNS resolution, which is not handled in ANTA. Please provide the DNS-resolved server name. - For example, 'ntp.example.com' in the configuration might resolve to 'ntp3.example.com' in the device output.""" - preferred: bool = False - """Optional preferred for NTP server. If not provided, it defaults to `False`.""" - stratum: int = Field(ge=0, le=16) - """NTP stratum level (0 to 15) where 0 is the reference clock and 16 indicates unsynchronized. - Values should be between 0 and 15 for valid synchronization and 16 represents an out-of-sync state.""" + NTPServer: ClassVar[type[NTPServer]] = NTPServer @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyNTPAssociations.""" - failures: str = "" + self.result.is_success() - if not (peer_details := get_value(self.instance_commands[0].json_output, "peers")): - self.result.is_failure("None of NTP peers are not configured.") + if not (peers := get_value(self.instance_commands[0].json_output, "peers")): + self.result.is_failure("No NTP peers configured") return # Iterate over each NTP server. for ntp_server in self.inputs.ntp_servers: server_address = str(ntp_server.server_address) - preferred = ntp_server.preferred - stratum = ntp_server.stratum - # Check if NTP server details exists. - if (peer_detail := get_value(peer_details, server_address, separator="..")) is None: - failures += f"NTP peer {server_address} is not configured.\n" - continue + # We check `peerIpAddr` in the peer details - covering IPv4Address input, or the peer key - covering Hostname input. + matching_peer = next((peer for peer, peer_details in peers.items() if (server_address in {peer_details["peerIpAddr"], peer})), None) - # Collecting the expected NTP peer details. - expected_peer_details = {"condition": "candidate", "stratum": stratum} - if preferred: - expected_peer_details["condition"] = "sys.peer" - - # Collecting the actual NTP peer details. - actual_peer_details = {"condition": get_value(peer_detail, "condition"), "stratum": get_value(peer_detail, "stratumLevel")} + if not matching_peer: + self.result.is_failure(f"{ntp_server} - Not configured") + continue - # Collecting failures logs if any. - failure_logs = get_failed_logs(expected_peer_details, actual_peer_details) - if failure_logs: - failures += f"For NTP peer {server_address}:{failure_logs}\n" + # Collecting the expected/actual NTP peer details. + exp_condition = "sys.peer" if ntp_server.preferred else "candidate" + exp_stratum = ntp_server.stratum + act_condition = get_value(peers[matching_peer], "condition") + act_stratum = get_value(peers[matching_peer], "stratumLevel") - # Check if there are any failures. - if not failures: - self.result.is_success() - else: - self.result.is_failure(failures) + if act_condition != exp_condition or act_stratum != exp_stratum: + self.result.is_failure(f"{ntp_server} - Bad association - Condition: {act_condition}, Stratum: {act_stratum}") diff --git a/anta/tests/vlan.py b/anta/tests/vlan.py index fdf91d896..b7b1bd4cb 100644 --- a/anta/tests/vlan.py +++ b/anta/tests/vlan.py @@ -38,7 +38,6 @@ class VerifyVlanInternalPolicy(AntaTest): ``` """ - name = "VerifyVlanInternalPolicy" description = "Verifies the VLAN internal allocation policy and the range of VLANs." categories: ClassVar[list[str]] = ["vlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy", revision=1)] diff --git a/anta/tests/vxlan.py b/anta/tests/vxlan.py index fe5381670..3236ef3bd 100644 --- a/anta/tests/vxlan.py +++ b/anta/tests/vxlan.py @@ -41,7 +41,6 @@ class VerifyVxlan1Interface(AntaTest): ``` """ - name = "VerifyVxlan1Interface" description = "Verifies the Vxlan1 interface status." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", revision=1)] @@ -65,7 +64,7 @@ def test(self) -> None: class VerifyVxlanConfigSanity(AntaTest): - """Verifies that no issues are detected with the VXLAN configuration. + """Verifies there are no VXLAN config-sanity inconsistencies. Expected Results ---------------- @@ -81,8 +80,6 @@ class VerifyVxlanConfigSanity(AntaTest): ``` """ - name = "VerifyVxlanConfigSanity" - description = "Verifies there are no VXLAN config-sanity inconsistencies." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", revision=1)] @@ -124,8 +121,6 @@ class VerifyVxlanVniBinding(AntaTest): ``` """ - name = "VerifyVxlanVniBinding" - description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", revision=1)] @@ -187,8 +182,6 @@ class VerifyVxlanVtep(AntaTest): ``` """ - name = "VerifyVxlanVtep" - description = "Verifies the VTEP peers of the Vxlan1 interface" categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", revision=1)] @@ -238,8 +231,6 @@ class VerifyVxlan1ConnSettings(AntaTest): ``` """ - name = "VerifyVxlan1ConnSettings" - description = "Verifies the interface vxlan1 source interface and UDP port." categories: ClassVar[list[str]] = ["vxlan"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces", revision=1)] diff --git a/anta/tools.py b/anta/tools.py index 4f73db9cb..8b116a0e0 100644 --- a/anta/tools.py +++ b/anta/tools.py @@ -94,8 +94,7 @@ def get_dict_superset( *, required: bool = False, ) -> Any: - """ - Get the first dictionary from a list of dictionaries that is a superset of the input dict. + """Get the first dictionary from a list of dictionaries that is a superset of the input dict. Returns the supplied default value or None if there is no match and "required" is False. @@ -378,7 +377,7 @@ def safe_command(command: str) -> str: def convert_categories(categories: list[str]) -> list[str]: """Convert categories for reports. - if the category is part of the defined acronym, transform it to upper case + If the category is part of the defined acronym, transform it to upper case otherwise capitalize the first letter. Parameters @@ -395,3 +394,24 @@ def convert_categories(categories: list[str]) -> list[str]: return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories] msg = f"Wrong input type '{type(categories)}' for convert_categories." raise TypeError(msg) + + +def format_data(data: dict[str, bool]) -> str: + """Format a data dictionary for logging purposes. + + Parameters + ---------- + data + A dictionary containing the data to format. + + Returns + ------- + str + The formatted data. + + Example + ------- + >>> format_data({"advertised": True, "received": True, "enabled": True}) + "Advertised: True, Received: True, Enabled: True" + """ + return ", ".join(f"{k.capitalize()}: {v}" for k, v in data.items()) diff --git a/asynceapi/__init__.py b/asynceapi/__init__.py index d6586cf9b..6d5a23bf6 100644 --- a/asynceapi/__init__.py +++ b/asynceapi/__init__.py @@ -9,4 +9,4 @@ from .device import Device from .errors import EapiCommandError -__all__ = ["Device", "SessionConfig", "EapiCommandError"] +__all__ = ["Device", "EapiCommandError", "SessionConfig"] diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py index 0cab94cb3..deac0431a 100644 --- a/asynceapi/aio_portcheck.py +++ b/asynceapi/aio_portcheck.py @@ -34,8 +34,7 @@ async def port_check_url(url: URL, timeout: int = 5) -> bool: - """ - Open the port designated by the URL given the timeout in seconds. + """Open the port designated by the URL given the timeout in seconds. Parameters ---------- diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py index df26d7def..7f83da41f 100644 --- a/asynceapi/config_session.py +++ b/asynceapi/config_session.py @@ -29,8 +29,7 @@ class SessionConfig: - """ - Send configuration to a device using the EOS session mechanism. + """Send configuration to a device using the EOS session mechanism. This is the preferred way of managing configuration changes. @@ -44,8 +43,7 @@ class SessionConfig: CLI_CFG_FACTORY_RESET = "rollback clean-config" def __init__(self, device: Device, name: str) -> None: - """ - Create a new instance of SessionConfig. + """Create a new instance of SessionConfig. The session config instance bound to the given device instance, and using the session `name`. @@ -81,8 +79,7 @@ def device(self) -> Device: # ------------------------------------------------------------------------- async def status_all(self) -> dict[str, Any]: - """ - Get the status of all the session config on the device. + """Get the status of all the session config on the device. Run the following command on the device: # show configuration sessions detail @@ -122,8 +119,7 @@ async def status_all(self) -> dict[str, Any]: return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any] async def status(self) -> dict[str, Any] | None: - """ - Get the status of a session config on the device. + """Get the status of a session config on the device. Run the following command on the device: # show configuration sessions detail @@ -179,8 +175,7 @@ async def status(self) -> dict[str, Any] | None: return res["sessions"].get(self.name) async def push(self, content: list[str] | str, *, replace: bool = False) -> None: - """ - Send the configuration content to the device. + """Send the configuration content to the device. If `replace` is true, then the command "rollback clean-config" is issued before sending the configuration content. @@ -218,8 +213,7 @@ async def push(self, content: list[str] | str, *, replace: bool = False) -> None await self._cli(commands=commands) async def commit(self, timer: str | None = None) -> None: - """ - Commit the session config. + """Commit the session config. Run the following command on the device: # configure session @@ -241,8 +235,7 @@ async def commit(self, timer: str | None = None) -> None: await self._cli(command) async def abort(self) -> None: - """ - Abort the configuration session. + """Abort the configuration session. Run the following command on the device: # configure session abort @@ -250,8 +243,7 @@ async def abort(self) -> None: await self._cli(f"{self._cli_config_session} abort") async def diff(self) -> str: - """ - Return the "diff" of the session config relative to the running config. + """Return the "diff" of the session config relative to the running config. Run the following command on the device: # show session-config named diffs @@ -268,8 +260,7 @@ async def diff(self) -> str: return await self._cli(f"show session-config named {self.name} diffs", ofmt="text") # type: ignore[return-value] # text outformat returns str async def load_file(self, filename: str, *, replace: bool = False) -> None: - """ - Load the configuration from into the session configuration. + """Load the configuration from into the session configuration. If the replace parameter is True then the file contents will replace the existing session config (load-replace). diff --git a/asynceapi/device.py b/asynceapi/device.py index 933ae649c..7793ce519 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -43,8 +43,7 @@ class Device(httpx.AsyncClient): - """ - Represent the async JSON-RPC client that communicates with an Arista EOS device. + """Represent the async JSON-RPC client that communicates with an Arista EOS device. This class inherits directly from the httpx.AsyncClient, so any initialization options can be passed directly. @@ -63,8 +62,7 @@ def __init__( port: str | int | None = None, **kwargs: Any, # noqa: ANN401 ) -> None: - """ - Initialize the Device class. + """Initialize the Device class. As a subclass to httpx.AsyncClient, the caller can provide any of those initializers. Specific parameters for Device class are all optional and described below. @@ -111,8 +109,7 @@ def __init__( self.headers["Content-Type"] = "application/json-rpc" async def check_connection(self) -> bool: - """ - Check the target device to ensure that the eAPI port is open and accepting connections. + """Check the target device to ensure that the eAPI port is open and accepting connections. It is recommended that a Caller checks the connection before involving cli commands, but this step is not required. @@ -136,8 +133,7 @@ async def cli( # noqa: PLR0913 expand_aliases: bool = False, req_id: int | str | None = None, ) -> list[dict[str, Any] | str] | dict[str, Any] | str | None: - """ - Execute one or more CLI commands. + """Execute one or more CLI commands. Parameters ---------- @@ -264,8 +260,7 @@ def _jsonrpc_command( # noqa: PLR0913 return cmd async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | str]: - """ - Execute the JSON-RPC dictionary object. + """Execute the JSON-RPC dictionary object. Parameters ---------- diff --git a/asynceapi/errors.py b/asynceapi/errors.py index e6794b7ef..5fce9db08 100644 --- a/asynceapi/errors.py +++ b/asynceapi/errors.py @@ -12,8 +12,7 @@ class EapiCommandError(RuntimeError): - """ - Exception class for EAPI command errors. + """Exception class for EAPI command errors. Attributes ---------- diff --git a/docs/advanced_usages/custom-tests.md b/docs/advanced_usages/custom-tests.md index d79fe50e3..8b217998c 100644 --- a/docs/advanced_usages/custom-tests.md +++ b/docs/advanced_usages/custom-tests.md @@ -36,8 +36,6 @@ class VerifyTemperature(AntaTest): ``` """ - name = "VerifyTemperature" - description = "Verifies the device temperature." categories: ClassVar[list[str]] = ["hardware"] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", revision=1)] @@ -61,8 +59,8 @@ Full AntaTest API documentation is available in the [API documentation section]( ### Class Attributes -- `name` (`str`): Name of the test. Used during reporting. -- `description` (`str`): A human readable description of your test. +- `name` (`str`, `optional`): Name of the test. Used during reporting. By default set to the Class name. +- `description` (`str`, `optional`): A human readable description of your test. By default set to the first line of the docstring. - `categories` (`list[str]`): A list of categories in which the test belongs. - `commands` (`[list[AntaCommand | AntaTemplate]]`): A list of command to collect from devices. This list **must** be a list of [AntaCommand](../api/models.md#anta.models.AntaCommand) or [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances. Rendering [AntaTemplate](../api/models.md#anta.models.AntaTemplate) instances will be discussed later. @@ -171,11 +169,11 @@ from anta.models import AntaTest, AntaCommand, AntaTemplate class (AntaTest): """ - + """ - name = "YourTestName" # should be your class name - description = "" + # name = # uncomment to override default behavior of name=Class Name + # description = # uncomment to override default behavior of description=first line of docstring categories = ["", ""] commands = [ AntaCommand( diff --git a/docs/api/test.cvx.md b/docs/api/test.cvx.md new file mode 100644 index 000000000..c9ff53dd2 --- /dev/null +++ b/docs/api/test.cvx.md @@ -0,0 +1,20 @@ +--- +anta_title: ANTA catalog for CVX tests +--- + + +::: anta.tests.cvx + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: + - "!test" + - "!render" diff --git a/docs/api/tests.bfd.md b/docs/api/tests.bfd.md index 719466e6d..ee950875d 100644 --- a/docs/api/tests.bfd.md +++ b/docs/api/tests.bfd.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for BFD tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.bfd + options: show_root_heading: false show_root_toc_entry: false @@ -18,3 +21,16 @@ anta_title: ANTA catalog for BFD tests filters: - "!test" - "!render" + +# Input models + +::: anta.input_models.bfd + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: ["!^__str__"] diff --git a/docs/api/tests.connectivity.md b/docs/api/tests.connectivity.md index 0dd5d4476..439cec89d 100644 --- a/docs/api/tests.connectivity.md +++ b/docs/api/tests.connectivity.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for connectivity tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.connectivity + options: show_root_heading: false show_root_toc_entry: false @@ -18,3 +21,16 @@ anta_title: ANTA catalog for connectivity tests filters: - "!test" - "!render" + +# Input models + +::: anta.input_models.connectivity + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: ["!^__str__"] diff --git a/docs/api/tests.interfaces.md b/docs/api/tests.interfaces.md index 95630f581..3d863ee2d 100644 --- a/docs/api/tests.interfaces.md +++ b/docs/api/tests.interfaces.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for interfaces tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.interfaces + options: show_root_heading: false show_root_toc_entry: false @@ -18,3 +21,16 @@ anta_title: ANTA catalog for interfaces tests filters: - "!test" - "!render" + +# Input models + +::: anta.input_models.interfaces + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: ["!^__str__"] diff --git a/docs/api/tests.routing.bgp.md b/docs/api/tests.routing.bgp.md index 4537ec24b..30e4362a0 100644 --- a/docs/api/tests.routing.bgp.md +++ b/docs/api/tests.routing.bgp.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for BGP tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.routing.bgp + options: show_root_heading: false show_root_toc_entry: false @@ -19,3 +22,20 @@ anta_title: ANTA catalog for BGP tests - "!test" - "!render" - "!^_[^_]" + +# Input models + +::: anta.input_models.routing.bgp + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + anta_hide_test_module_description: true + merge_init_into_class: false + show_labels: true + filters: + - "!^__init__" + - "!^__str__" + - "!AFI_SAFI_EOS_KEY" + - "!eos_key" diff --git a/docs/api/tests.routing.isis.md b/docs/api/tests.routing.isis.md index bf50c72e7..16ca7ffeb 100644 --- a/docs/api/tests.routing.isis.md +++ b/docs/api/tests.routing.isis.md @@ -8,6 +8,7 @@ anta_title: ANTA catalog for IS-IS tests --> ::: anta.tests.routing.isis + options: show_root_heading: false show_root_toc_entry: false diff --git a/docs/api/tests.routing.ospf.md b/docs/api/tests.routing.ospf.md index 2fd0cd410..12bb3ec1f 100644 --- a/docs/api/tests.routing.ospf.md +++ b/docs/api/tests.routing.ospf.md @@ -8,6 +8,7 @@ anta_title: ANTA catalog for OSPF tests --> ::: anta.tests.routing.ospf + options: show_root_heading: false show_root_toc_entry: false diff --git a/docs/api/tests.services.md b/docs/api/tests.services.md index 63d9234c0..cd371489f 100644 --- a/docs/api/tests.services.md +++ b/docs/api/tests.services.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for services tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.services + options: show_root_heading: false show_root_toc_entry: false @@ -18,3 +21,16 @@ anta_title: ANTA catalog for services tests filters: - "!test" - "!render" + +# Input models + +::: anta.input_models.services + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: ["!^__str__"] diff --git a/docs/api/tests.system.md b/docs/api/tests.system.md index 5dcfbc026..26568e205 100644 --- a/docs/api/tests.system.md +++ b/docs/api/tests.system.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for System tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.system + options: show_root_heading: false show_root_toc_entry: false @@ -18,3 +21,16 @@ anta_title: ANTA catalog for System tests filters: - "!test" - "!render" + +# Input models + +::: anta.input_models.system + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: ["!^__str__"] diff --git a/docs/cli/exec.md b/docs/cli/exec.md index 2eb12eec5..4f6d5d169 100644 --- a/docs/cli/exec.md +++ b/docs/cli/exec.md @@ -235,9 +235,10 @@ Options: tag1,tag2,tag3. [env var: ANTA_TAGS] -o, --output PATH Path for test catalog [default: ./tech-support] --latest INTEGER Number of scheduled show-tech to retrieve - --configure Ensure devices have 'aaa authorization exec default - local' configured (required for SCP on EOS). THIS - WILL CHANGE THE CONFIGURATION OF YOUR NETWORK. + --configure [DEPRECATED] Ensure devices have 'aaa authorization + exec default local' configured (required for SCP on + EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR + NETWORK. --help Show this message and exit. ``` @@ -248,7 +249,10 @@ When executed, this command fetches tech-support files and downloads them locall ANTA uses SCP to download files from devices and will not trust unknown SSH hosts by default. Add the SSH public keys of your devices to your `known_hosts` file or use the `anta --insecure` option to ignore SSH host keys validation. The configuration `aaa authorization exec default` must be present on devices to be able to use SCP. -ANTA can automatically configure `aaa authorization exec default local` using the `anta exec collect-tech-support --configure` option. + +!!! warning Deprecation + ANTA can automatically configure `aaa authorization exec default local` using the `anta exec collect-tech-support --configure` option but this option is deprecated and will be removed in ANTA 2.0.0. + If you require specific AAA configuration for `aaa authorization exec default`, like `aaa authorization exec default none` or `aaa authorization exec default group tacacs+`, you will need to configure it manually. The `--latest` option allows retrieval of a specific number of the most recent tech-support files. diff --git a/docs/cli/inv-from-ansible.md b/docs/cli/inv-from-ansible.md index 6bbaca926..f7cc54a1f 100644 --- a/docs/cli/inv-from-ansible.md +++ b/docs/cli/inv-from-ansible.md @@ -31,26 +31,12 @@ Options: --help Show this message and exit. ``` -!!! warning +!!! warning "Warnings" - `anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. - If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for `from-ansible` command to work." + * `anta get from-ansible` does not support inline vaulted variables, comment them out to generate your inventory. + If the vaulted variable is necessary to build the inventory (e.g. `ansible_host`), it needs to be unvaulted for `from-ansible` command to work." -The output is an inventory where the name of the container is added as a tag for each host: - -```yaml -anta_inventory: - hosts: - - host: 10.73.252.41 - name: srv-pod01 - - host: 10.73.252.42 - name: srv-pod02 - - host: 10.73.252.43 - name: srv-pod03 -``` - -!!! warning - The current implementation only considers devices directly attached to a specific Ansible group and does not support inheritance when using the `--ansible-group` option. + * The current implementation only considers devices directly attached to a specific Ansible group and does not support inheritance when using the `--ansible-group` option. By default, if user does not provide `--output` file, anta will save output to configured anta inventory (`anta --inventory`). If the output file has content, anta will ask user to overwrite when running in interactive console. This mechanism can be controlled by triggers in case of CI usage: `--overwrite` to force anta to overwrite file. If not set, anta will exit @@ -60,7 +46,7 @@ By default, if user does not provide `--output` file, anta will save output to c ```yaml --- -tooling: +all: children: endpoints: hosts: @@ -80,3 +66,16 @@ tooling: ansible_host: 10.73.252.43 type: endpoint ``` + +The output is an inventory where the name of the container is added as a tag for each host: + +```yaml +anta_inventory: + hosts: + - host: 10.73.252.41 + name: srv-pod01 + - host: 10.73.252.42 + name: srv-pod02 + - host: 10.73.252.43 + name: srv-pod03 +``` diff --git a/docs/cli/tag-management.md b/docs/cli/tag-management.md index ad5ccf3ab..4108d75bb 100644 --- a/docs/cli/tag-management.md +++ b/docs/cli/tag-management.md @@ -4,9 +4,7 @@ ~ that can be found in the LICENSE file. --> -ANTA commands can be used with a `--tags` option. This option **filters the inventory** with the specified tag(s) when running the command. - -Tags can also be used to **restrict a specific test** to a set of devices when using `anta nrfu`. +ANTA uses tags to define test-to-device mappings (tests run on devices with matching tags) and the `--tags` CLI option acts as a filter to execute specific test/device combinations. ## Defining tags diff --git a/docs/getting-started.md b/docs/getting-started.md index aac88c640..bcd5a2c51 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,26 +48,7 @@ management api http-commands ANTA uses an inventory to list the target devices for the tests. You can create a file manually with this format: ```yaml -anta_inventory: - hosts: - - host: 192.168.0.10 - name: spine01 - tags: ['fabric', 'spine'] - - host: 192.168.0.11 - name: spine02 - tags: ['fabric', 'spine'] - - host: 192.168.0.12 - name: leaf01 - tags: ['fabric', 'leaf'] - - host: 192.168.0.13 - name: leaf02 - tags: ['fabric', 'leaf'] - - host: 192.168.0.14 - name: leaf03 - tags: ['fabric', 'leaf'] - - host: 192.168.0.15 - name: leaf04 - tags: ['fabric', 'leaf'] +--8<-- "getting-started/inventory.yml" ``` > You can read more details about how to build your inventory [here](usage-inventory-catalog.md#device-inventory) @@ -90,31 +71,7 @@ The structure to follow is like: Here is an example for basic tests: ```yaml -# Load anta.tests.software -anta.tests.software: - - VerifyEOSVersion: # Verifies the device is running one of the allowed EOS version. - versions: # List of allowed EOS versions. - - 4.25.4M - - 4.26.1F - - '4.28.3M-28837868.4283M (engineering build)' - - VerifyTerminAttrVersion: - versions: - - v1.22.1 - -anta.tests.system: - - VerifyUptime: # Verifies the device uptime is higher than a value. - minimum: 1 - - VerifyNTP: - - VerifySyslog: - -anta.tests.mlag: - - VerifyMlagStatus: - - VerifyMlagInterfaces: - - VerifyMlagConfigSanity: - -anta.tests.configuration: - - VerifyZeroTouch: # Verifies ZeroTouch is disabled. - - VerifyRunningConfigDiffs: +--8<-- "getting-started/catalog.yml" ``` ## Test your network @@ -135,128 +92,32 @@ This entrypoint has multiple options to manage test coverage and reporting. To run the NRFU, you need to select an output format amongst ["json", "table", "text", "tpl-report"]. For a first usage, `table` is recommended. By default all test results for all devices are rendered but it can be changed to a report per test case or per host +!!! Note + The following examples shows how to pass all the CLI options. + + See how to use environment variables instead in the [CLI overview](cli/overview.md#anta-environment-variables) + #### Default report using table ```bash -anta nrfu \ - --username tom \ - --password arista123 \ - --enable \ - --enable-password t \ - --inventory .personal/inventory_atd.yml \ - --catalog .personal/tests-bases.yml \ - table --tags leaf - - -╭────────────────────── Settings ──────────────────────╮ -│ Running ANTA tests: │ -│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ -│ - Tests catalog contains 10 tests │ -╰──────────────────────────────────────────────────────╯ -[10:17:24] INFO Running ANTA tests... runner.py:75 - • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40/40 • 0:00:02 • 0:00:00 - - All tests results -┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ -┃ Device IP ┃ Test Name ┃ Test Status ┃ Message(s) ┃ Test description ┃ Test category ┃ -┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ -│ leaf01 │ VerifyEOSVersion │ success │ │ Verifies the device is running one of the allowed EOS version. │ software │ -│ leaf01 │ VerifyTerminAttrVersion │ success │ │ Verifies the device is running one of the allowed TerminAttr │ software │ -│ │ │ │ │ version. │ │ -│ leaf01 │ VerifyUptime │ success │ │ Verifies the device uptime is higher than a value. │ system │ -│ leaf01 │ VerifyNTP │ success │ │ Verifies NTP is synchronised. │ system │ -│ leaf01 │ VerifySyslog │ success │ │ Verifies the device had no syslog message with a severity of warning │ system │ -│ │ │ │ │ (or a more severe message) during the last 7 days. │ │ -│ leaf01 │ VerifyMlagStatus │ skipped │ MLAG is disabled │ This test verifies the health status of the MLAG configuration. │ mlag │ -│ leaf01 │ VerifyMlagInterfaces │ skipped │ MLAG is disabled │ This test verifies there are no inactive or active-partial MLAG │ mlag │ -[...] -│ leaf04 │ VerifyMlagConfigSanity │ skipped │ MLAG is disabled │ This test verifies there are no MLAG config-sanity inconsistencies. │ mlag │ -│ leaf04 │ VerifyZeroTouch │ success │ │ Verifies ZeroTouch is disabled. │ configuration │ -│ leaf04 │ VerifyRunningConfigDiffs │ success │ │ │ configuration │ -└───────────┴──────────────────────────┴─────────────┴──────────────────┴──────────────────────────────────────────────────────────────────────┴───────────────┘ +--8<-- "getting-started/anta_nrfu_table.sh" +--8<-- "getting-started/anta_nrfu_table.output" ``` #### Report in text mode ```bash -$ anta nrfu \ - --username tom \ - --password arista123 \ - --enable \ - --enable-password t \ - --inventory .personal/inventory_atd.yml \ - --catalog .personal/tests-bases.yml \ - text --tags leaf - -╭────────────────────── Settings ──────────────────────╮ -│ Running ANTA tests: │ -│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ -│ - Tests catalog contains 10 tests │ -╰──────────────────────────────────────────────────────╯ -[10:20:47] INFO Running ANTA tests... runner.py:75 - • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40/40 • 0:00:01 • 0:00:00 -leaf01 :: VerifyEOSVersion :: SUCCESS -leaf01 :: VerifyTerminAttrVersion :: SUCCESS -leaf01 :: VerifyUptime :: SUCCESS -leaf01 :: VerifyNTP :: SUCCESS -leaf01 :: VerifySyslog :: SUCCESS -leaf01 :: VerifyMlagStatus :: SKIPPED (MLAG is disabled) -leaf01 :: VerifyMlagInterfaces :: SKIPPED (MLAG is disabled) -leaf01 :: VerifyMlagConfigSanity :: SKIPPED (MLAG is disabled) -[...] +--8<-- "getting-started/anta_nrfu_text.sh" +--8<-- "getting-started/anta_nrfu_text.output" ``` #### Report in JSON format ```bash -$ anta nrfu \ - --username tom \ - --password arista123 \ - --enable \ - --enable-password t \ - --inventory .personal/inventory_atd.yml \ - --catalog .personal/tests-bases.yml \ - json --tags leaf - -╭────────────────────── Settings ──────────────────────╮ -│ Running ANTA tests: │ -│ - ANTA Inventory contains 6 devices (AsyncEOSDevice) │ -│ - Tests catalog contains 10 tests │ -╰──────────────────────────────────────────────────────╯ -[10:21:51] INFO Running ANTA tests... runner.py:75 - • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40/40 • 0:00:02 • 0:00:00 -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ JSON results of all tests │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -[ - { - "name": "leaf01", - "test": "VerifyEOSVersion", - "categories": [ - "software" - ], - "description": "Verifies the device is running one of the allowed EOS version.", - "result": "success", - "messages": [], - "custom_field": "None", - }, - { - "name": "leaf01", - "test": "VerifyTerminAttrVersion", - "categories": [ - "software" - ], - "description": "Verifies the device is running one of the allowed TerminAttr version.", - "result": "success", - "messages": [], - "custom_field": "None", - }, -[...] -] +--8<-- "getting-started/anta_nrfu_json.sh" +--8<-- "getting-started/anta_nrfu_json.output" ``` -You can find more information under the **usage** section of the website - ### Basic usage in a Python script ```python diff --git a/docs/scripts/generate_svg.py b/docs/scripts/generate_svg.py index f017b243d..2eca6ac4a 100644 --- a/docs/scripts/generate_svg.py +++ b/docs/scripts/generate_svg.py @@ -94,7 +94,7 @@ def custom_progress_bar() -> Progress: # Redirect stdout of the program towards another StringIO to capture help # that is not part or anta rich console # redirect potential progress bar output to console by patching - with patch("anta.cli.nrfu.anta_progress_bar", custom_progress_bar), suppress(SystemExit): + with patch("anta.cli.nrfu.utils.anta_progress_bar", custom_progress_bar), suppress(SystemExit): function() if "--help" in args: diff --git a/docs/snippets/getting-started/anta_nrfu_json.output b/docs/snippets/getting-started/anta_nrfu_json.output new file mode 100644 index 000000000..c6db49d43 --- /dev/null +++ b/docs/snippets/getting-started/anta_nrfu_json.output @@ -0,0 +1,54 @@ +╭────────────────────── Settings ──────────────────────╮ +│ - ANTA Inventory contains 5 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 9 tests │ +╰──────────────────────────────────────────────────────╯ + +[10:53:11] INFO Preparing ANTA NRFU Run ... tools.py:294 + INFO Connecting to devices ... tools.py:294 + INFO Connecting to devices completed in: 0:00:00.053. tools.py:302 + INFO Preparing the tests ... tools.py:294 + INFO Preparing the tests completed in: 0:00:00.001. tools.py:302 + INFO --- ANTA NRFU Run Information --- runner.py:276 + Number of devices: 5 (5 established) + Total number of selected tests: 45 + Maximum number of open file descriptors for the current ANTA process: 16384 + --------------------------------- + INFO Preparing ANTA NRFU Run completed in: 0:00:00.065. tools.py:302 + INFO Running ANTA tests ... tools.py:294 +[10:53:12] INFO Running ANTA tests completed in: 0:00:00.857. tools.py:302 + INFO Cache statistics for 's1-spine1': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-spine2': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf1': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf2': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf3': 1 hits / 9 command(s) (11.11%) runner.py:75 + • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45/45 • 0:00:00 • 0:00:00 + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ JSON results │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +[ + { + "name": "s1-spine1", + "test": "VerifyNTP", + "categories": [ + "system" + ], + "description": "Verifies if NTP is synchronised.", + "result": "success", + "messages": [], + "custom_field": null + }, + { + "name": "s1-spine1", + "test": "VerifyMlagConfigSanity", + "categories": [ + "mlag" + ], + "description": "Verifies there are no MLAG config-sanity inconsistencies.", + "result": "skipped", + "messages": [ + "MLAG is disabled" + ], + "custom_field": null + }, + [...] diff --git a/docs/snippets/getting-started/anta_nrfu_json.sh b/docs/snippets/getting-started/anta_nrfu_json.sh new file mode 100644 index 000000000..932aeb33c --- /dev/null +++ b/docs/snippets/getting-started/anta_nrfu_json.sh @@ -0,0 +1,9 @@ +anta nrfu \ + --username arista \ + --password arista \ + --inventory ./inventory.yml \ + `# uncomment the next two lines if you have an enable password `\ + `# --enable `\ + `# --enable-password `\ + --catalog ./catalog.yml \ + json diff --git a/docs/snippets/getting-started/anta_nrfu_table.output b/docs/snippets/getting-started/anta_nrfu_table.output new file mode 100644 index 000000000..a34b5bd99 --- /dev/null +++ b/docs/snippets/getting-started/anta_nrfu_table.output @@ -0,0 +1,47 @@ +╭────────────────────── Settings ──────────────────────╮ +│ - ANTA Inventory contains 5 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 9 tests │ +╰──────────────────────────────────────────────────────╯ + +[10:53:01] INFO Preparing ANTA NRFU Run ... tools.py:294 + INFO Connecting to devices ... tools.py:294 + INFO Connecting to devices completed in: 0:00:00.058. tools.py:302 + INFO Preparing the tests ... tools.py:294 + INFO Preparing the tests completed in: 0:00:00.001. tools.py:302 + INFO --- ANTA NRFU Run Information --- runner.py:276 + Number of devices: 5 (5 established) + Total number of selected tests: 45 + Maximum number of open file descriptors for the current ANTA process: 16384 + --------------------------------- + INFO Preparing ANTA NRFU Run completed in: 0:00:00.069. tools.py:302 + INFO Running ANTA tests ... tools.py:294 +[10:53:02] INFO Running ANTA tests completed in: 0:00:00.969. tools.py:302 + INFO Cache statistics for 's1-spine1': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-spine2': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf1': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf2': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf3': 1 hits / 9 command(s) (11.11%) runner.py:75 + • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45/45 • 0:00:00 • 0:00:00 + + All tests results +┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ +┃ Device ┃ Test Name ┃ Test Status ┃ Message(s) ┃ Test description ┃ Test category ┃ +┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ +│ s1-spine1 │ VerifyMlagConfigSanity │ skipped │ MLAG is disabled │ Verifies there are no MLAG config-sanity │ MLAG │ +│ │ │ │ │ inconsistencies. │ │ +├───────────┼──────────────────────────┼─────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼───────────────┤ +│ s1-spine1 │ VerifyEOSVersion │ failure │ device is running version │ Verifies the EOS version of the device. │ Software │ +│ │ │ │ "4.32.2F-38195967.4322F (engineering │ │ │ +│ │ │ │ build)" not in expected versions: │ │ │ +│ │ │ │ ['4.25.4M', '4.26.1F', │ │ │ +│ │ │ │ '4.28.3M-28837868.4283M (engineering │ │ │ +│ │ │ │ build)'] │ │ │ +├───────────┼──────────────────────────┼─────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼───────────────┤ +[...] +├───────────┼──────────────────────────┼─────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼───────────────┤ +│ s1-leaf3 │ VerifyTerminAttrVersion │ failure │ device is running TerminAttr version │ Verifies the TerminAttr version of the │ Software │ +│ │ │ │ v1.34.0 and is not in the allowed list: │ device. │ │ +│ │ │ │ ['v1.22.1'] │ │ │ +├───────────┼──────────────────────────┼─────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼───────────────┤ +│ s1-leaf3 │ VerifyZeroTouch │ success │ │ Verifies ZeroTouch is disabled │ Configuration │ +└───────────┴──────────────────────────┴─────────────┴────────────────────────────────────────────┴────────────────────────────────────────────┴───────────────┘ diff --git a/docs/snippets/getting-started/anta_nrfu_table.sh b/docs/snippets/getting-started/anta_nrfu_table.sh new file mode 100644 index 000000000..785c41854 --- /dev/null +++ b/docs/snippets/getting-started/anta_nrfu_table.sh @@ -0,0 +1,10 @@ +anta nrfu \ + --username arista \ + --password arista \ + --inventory ./inventory.yml \ + `# uncomment the next two lines if you have an enable password `\ + `# --enable` \ + `# --enable-password ` \ + --catalog ./catalog.yml \ + `# table is default if not provided` \ + table diff --git a/docs/snippets/getting-started/anta_nrfu_text.output b/docs/snippets/getting-started/anta_nrfu_text.output new file mode 100644 index 000000000..872f60810 --- /dev/null +++ b/docs/snippets/getting-started/anta_nrfu_text.output @@ -0,0 +1,30 @@ +╭────────────────────── Settings ──────────────────────╮ +│ - ANTA Inventory contains 5 devices (AsyncEOSDevice) │ +│ - Tests catalog contains 9 tests │ +╰──────────────────────────────────────────────────────╯ + +[10:52:39] INFO Preparing ANTA NRFU Run ... tools.py:294 + INFO Connecting to devices ... tools.py:294 + INFO Connecting to devices completed in: 0:00:00.057. tools.py:302 + INFO Preparing the tests ... tools.py:294 + INFO Preparing the tests completed in: 0:00:00.001. tools.py:302 + INFO --- ANTA NRFU Run Information --- runner.py:276 + Number of devices: 5 (5 established) + Total number of selected tests: 45 + Maximum number of open file descriptors for the current ANTA process: 16384 + --------------------------------- + INFO Preparing ANTA NRFU Run completed in: 0:00:00.068. tools.py:302 + INFO Running ANTA tests ... tools.py:294 +[10:52:40] INFO Running ANTA tests completed in: 0:00:00.863. tools.py:302 + INFO Cache statistics for 's1-spine1': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-spine2': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf1': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf2': 1 hits / 9 command(s) (11.11%) runner.py:75 + INFO Cache statistics for 's1-leaf3': 1 hits / 9 command(s) (11.11%) runner.py:75 + • Running NRFU Tests...100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45/45 • 0:00:00 • 0:00:00 + +s1-spine1 :: VerifyEOSVersion :: FAILURE(device is running version "4.32.2F-38195967.4322F (engineering build)" not in expected versions: ['4.25.4M', '4.26.1F', +'4.28.3M-28837868.4283M (engineering build)']) +s1-spine1 :: VerifyTerminAttrVersion :: FAILURE(device is running TerminAttr version v1.34.0 and is not in the allowed list: ['v1.22.1']) +s1-spine1 :: VerifyZeroTouch :: SUCCESS() +s1-spine1 :: VerifyMlagConfigSanity :: SKIPPED(MLAG is disabled) diff --git a/docs/snippets/getting-started/anta_nrfu_text.sh b/docs/snippets/getting-started/anta_nrfu_text.sh new file mode 100644 index 000000000..3835b51c8 --- /dev/null +++ b/docs/snippets/getting-started/anta_nrfu_text.sh @@ -0,0 +1,9 @@ +anta nrfu \ + --username arista \ + --password arista \ + --inventory ./inventory.yml \ + `# uncomment the next two lines if you have an enable password `\ + `# --enable` \ + `# --enable-password ` \ + --catalog ./catalog.yml \ + text diff --git a/docs/snippets/getting-started/catalog.yml b/docs/snippets/getting-started/catalog.yml new file mode 100644 index 000000000..cc7e7810a --- /dev/null +++ b/docs/snippets/getting-started/catalog.yml @@ -0,0 +1,24 @@ +--- +anta.tests.software: + - VerifyEOSVersion: # Verifies the device is running one of the allowed EOS version. + versions: # List of allowed EOS versions. + - 4.25.4M + - 4.26.1F + - '4.28.3M-28837868.4283M (engineering build)' + - VerifyTerminAttrVersion: + versions: + - v1.22.1 + +anta.tests.system: + - VerifyUptime: # Verifies the device uptime is higher than a value. + minimum: 1 + - VerifyNTP: + +anta.tests.mlag: + - VerifyMlagStatus: + - VerifyMlagInterfaces: + - VerifyMlagConfigSanity: + +anta.tests.configuration: + - VerifyZeroTouch: # Verifies ZeroTouch is disabled. + - VerifyRunningConfigDiffs: diff --git a/docs/snippets/getting-started/inventory.yml b/docs/snippets/getting-started/inventory.yml new file mode 100644 index 000000000..2f3d5127a --- /dev/null +++ b/docs/snippets/getting-started/inventory.yml @@ -0,0 +1,20 @@ +anta_inventory: + hosts: + - host: 192.168.0.10 + name: s1-spine1 + tags: ['fabric', 'spine'] + - host: 192.168.0.11 + name: s1-spine2 + tags: ['fabric', 'spine'] + - host: 192.168.0.12 + name: s1-leaf1 + tags: ['fabric', 'leaf'] + - host: 192.168.0.13 + name: s1-leaf2 + tags: ['fabric', 'leaf'] + - host: 192.168.0.14 + name: s1-leaf3 + tags: ['fabric', 'leaf'] + - host: 192.168.0.15 + name: s1-leaf3 + tags: ['fabric', 'leaf'] diff --git a/docs/templates/python/material/anta_test_input_model.html.jinja b/docs/templates/python/material/anta_test_input_model.html.jinja new file mode 100644 index 000000000..f867ad00d --- /dev/null +++ b/docs/templates/python/material/anta_test_input_model.html.jinja @@ -0,0 +1,154 @@ +{% if obj.members %} + {{ log.debug("Rendering children of " + obj.path) }} + +
+ + {% if root_members %} + {% set members_list = config.members %} + {% else %} + {% set members_list = none %} + {% endif %} + + {% if config.group_by_category %} + + {% with %} + + {% if config.show_category_heading %} + {% set extra_level = 1 %} + {% else %} + {% set extra_level = 0 %} + {% endif %} + + {% with attributes = obj.attributes|filter_objects( + filters=config.filters, + members_list=members_list, + inherited_members=config.inherited_members, + keep_no_docstrings=config.show_if_no_docstring, + ) %} + {% if attributes %} + {% if config.show_category_heading %} + {% filter heading(heading_level, id=html_id ~ "-attributes") %}Attributes{% endfilter %} + {% endif %} + {% with heading_level = heading_level + extra_level %} + {% set root = False %} + {% set heading_level = heading_level + 1 %} + {% set old_obj = obj %} + {% set obj = class %} + {% include "attributes_table.html" with context %} + {% set obj = old_obj %} + {% endwith %} + {% endif %} + {% endwith %} + + {% with classes = obj.classes|filter_objects( + filters=config.filters, + members_list=members_list, + inherited_members=config.inherited_members, + keep_no_docstrings=config.show_if_no_docstring, + ) %} + {% if classes %} + {% if config.show_category_heading %} + {% filter heading(heading_level, id=html_id ~ "-classes") %}Classes{% endfilter %} + {% endif %} + {% with heading_level = heading_level + extra_level %} + {% for class in classes|order_members(config.members_order, members_list) %} + {% if members_list is not none or class.is_public %} + {% include class|get_template with context %} + {% endif %} + {% endfor %} + {% endwith %} + {% endif %} + {% endwith %} + + {% with functions = obj.functions|filter_objects( + filters=config.filters, + members_list=members_list, + inherited_members=config.inherited_members, + keep_no_docstrings=config.show_if_no_docstring, + ) %} + {% if functions %} + {% if config.show_category_heading %} + {% filter heading(heading_level, id=html_id ~ "-functions") %}Functions{% endfilter %} + {% endif %} + {% with heading_level = heading_level + extra_level %} + {% for function in functions|order_members(config.members_order, members_list) %} + {% if not (obj.kind.value == "class" and function.name == "__init__" and config.merge_init_into_class) %} + {% if members_list is not none or function.is_public %} + {% include function|get_template with context %} + {% endif %} + {% endif %} + {% endfor %} + {% endwith %} + {% endif %} + {% endwith %} + + {% if config.show_submodules %} + {% with modules = obj.modules|filter_objects( + filters=config.filters, + members_list=members_list, + inherited_members=config.inherited_members, + keep_no_docstrings=config.show_if_no_docstring, + ) %} + {% if modules %} + {% if config.show_category_heading %} + {% filter heading(heading_level, id=html_id ~ "-modules") %}Modules{% endfilter %} + {% endif %} + {% with heading_level = heading_level + extra_level %} + {% for module in modules|order_members(config.members_order.alphabetical, members_list) %} + {% if members_list is not none or module.is_public %} + {% include module|get_template with context %} + {% endif %} + {% endfor %} + {% endwith %} + {% endif %} + {% endwith %} + {% endif %} + + {% endwith %} + + {% else %} + + {% for child in obj.all_members + |filter_objects( + filters=config.filters, + members_list=members_list, + inherited_members=config.inherited_members, + keep_no_docstrings=config.show_if_no_docstring, + ) + |order_members(config.members_order, members_list) + %} + + {% if not (obj.is_class and child.name == "__init__" and config.merge_init_into_class) %} + + {% if members_list is not none or child.is_public %} + {% if child.is_attribute %} + {% with attribute = child %} + {% include attribute|get_template with context %} + {% endwith %} + + {% elif child.is_class %} + {% with class = child %} + {% include class|get_template with context %} + {% endwith %} + + {% elif child.is_function %} + {% with function = child %} + {% include function|get_template with context %} + {% endwith %} + + {% elif child.is_module and config.show_submodules %} + {% with module = child %} + {% include module|get_template with context %} + {% endwith %} + + {% endif %} + {% endif %} + + {% endif %} + + {% endfor %} + + {% endif %} + +
+{% endif %} diff --git a/docs/templates/python/material/class.html.jinja b/docs/templates/python/material/class.html.jinja index 1c1173ce4..cf016c92e 100644 --- a/docs/templates/python/material/class.html.jinja +++ b/docs/templates/python/material/class.html.jinja @@ -1,26 +1,46 @@ {% extends "_base/class.html.jinja" %} {% set anta_test = namespace(found=false) %} +{% set anta_test_input_model = namespace(found=false) %} {% for base in class.bases %} {% set basestr = base | string %} {% if "AntaTest" == basestr %} {% set anta_test.found = True %} {% endif %} {% endfor %} +{# TODO make this nicer #} +{% if class.parent.parent.name == "input_models" or class.parent.parent.parent.name == "input_models" %} +{% set anta_test_input_model.found = True %} +{% endif %} {% block children %} {% if anta_test.found %} {% set root = False %} {% set heading_level = heading_level + 1 %} {% include "anta_test.html.jinja" with context %} {# render source after children - TODO make add flag to respect disabling it.. though do we want to disable?#} -
- Source code in - {%- if class.relative_filepath.is_absolute() -%} - {{ class.relative_package_filepath }} - {%- else -%} - {{ class.relative_filepath }} - {%- endif -%} - - {{ class.source|highlight(language="python", linestart=class.lineno, linenums=True) }} +
+ Source code in + {%- if class.relative_filepath.is_absolute() -%} + {{ class.relative_package_filepath }} + {%- else -%} + {{ class.relative_filepath }} + {%- endif -%} + + {{ class.source|highlight(language="python", linestart=class.lineno, linenums=True) }} +
+{% elif anta_test_input_model.found %} + {% set root = False %} + {% set heading_level = heading_level + 1 %} + {% include "anta_test_input_model.html.jinja" with context %} + {# render source after children - TODO make add flag to respect disabling it.. though do we want to disable?#} +
+ Source code in + {%- if class.relative_filepath.is_absolute() -%} + {{ class.relative_package_filepath }} + {%- else -%} + {{ class.relative_filepath }} + {%- endif -%} + + {{ class.source|highlight(language="python", linestart=class.lineno, linenums=True) }}
{% else %} {{ super() }} @@ -29,7 +49,7 @@ {# Do not render source before children for AntaTest #} {% block source %} -{% if not anta_test.found %} +{% if not anta_test.found and not anta_test_input_model%} {{ super() }} {% endif %} {% endblock source %} diff --git a/examples/tests.yaml b/examples/tests.yaml index 16c16af14..892560460 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -99,6 +99,11 @@ anta.tests.configuration: - "^enable password.*$" - "bla bla" +anta.tests.cvx: + - VerifyMcsClientMounts: + - VerifyManagementCVX: + enabled: true + anta.tests.connectivity: - VerifyReachability: hosts: diff --git a/mkdocs.yml b/mkdocs.yml index 100042eac..3a321daaf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,47 +122,44 @@ plugins: width: 90vw markdown_extensions: + - admonition - attr_list - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - smarty + - codehilite: + guess_lang: true - pymdownx.arithmatex - pymdownx.betterem: smart_enable: all - pymdownx.caret - pymdownx.critic - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight - pymdownx.inlinehilite - pymdownx.magiclink - pymdownx.mark - pymdownx.smartsymbols + - pymdownx.snippets: + base_path: + - docs/snippets + - examples - pymdownx.superfences: custom_fences: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true - pymdownx.tasklist: custom_checkbox: true - pymdownx.tilde - # - fontawesome_markdown - - admonition - - codehilite: - guess_lang: true + - smarty - toc: separator: "-" # permalink: "#" permalink: true baselevel: 2 - - pymdownx.highlight - - pymdownx.snippets: - base_path: - - docs/snippets - - examples - - pymdownx.superfences - - pymdownx.superfences - - pymdownx.tabbed: - alternate_style: true # TOC docs_dir: docs/ diff --git a/pyproject.toml b/pyproject.toml index bc7ac7127..0bdccf2fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Testing", @@ -75,7 +76,7 @@ dev = [ "pytest-metadata>=3.0.0", "pytest>=7.4.0", "respx>=0.21.1", - "ruff>=0.5.4,<0.7.0", + "ruff>=0.5.4,<0.9.0", "tox>=4.10.0,<5.0.0", "types-PyYAML", "types-pyOpenSSL", @@ -238,7 +239,7 @@ envlist = clean, lint, type, - py{39,310,311,312}, + py{39,310,311,312,313}, report [gh-actions] @@ -247,6 +248,7 @@ python = 3.10: py310 3.11: erase, py311, report 3.12: py312 + 3.13: py313 [testenv] description = Run pytest with {basepython} @@ -337,10 +339,9 @@ select = ["ALL", # By enabling a convention for docstrings, ruff automatically ignore some rules that need to be # added back if we want them. # https://docs.astral.sh/ruff/faq/#does-ruff-support-numpy-or-google-style-docstrings - # TODO: Augment the numpy convention rules to make sure we add all the params - # Uncomment below D417 "D415", "D417", + "D212", ] ignore = [ "COM812", # Ignoring conflicting rules that may cause conflicts when used with the formatter diff --git a/tests/data/test_catalog_double_failure.yml b/tests/data/test_catalog_double_failure.yml new file mode 100644 index 000000000..0ce48f8d3 --- /dev/null +++ b/tests/data/test_catalog_double_failure.yml @@ -0,0 +1,13 @@ +--- +anta.tests.interfaces: + - VerifyInterfacesSpeed: + interfaces: + - name: Ethernet2 + auto: False + speed: 10 + - name: Ethernet3 + auto: True + speed: 100 + - name: Ethernet4 + auto: False + speed: 2.5 diff --git a/tests/data/test_inventory_with_tags.yml b/tests/data/test_inventory_with_tags.yml index cbbcd75e6..16a9df4c0 100644 --- a/tests/data/test_inventory_with_tags.yml +++ b/tests/data/test_inventory_with_tags.yml @@ -3,7 +3,7 @@ anta_inventory: hosts: - name: leaf1 host: leaf1.anta.arista.com - tags: ["leaf"] + tags: ["leaf", "dc1"] - name: leaf2 host: leaf2.anta.arista.com tags: ["leaf"] diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index e256b04dd..c5b8cedb6 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -6,8 +6,11 @@ # pylint: disable=C0302 from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any +import pytest + +from anta.input_models.routing.bgp import BgpAddressFamily from anta.tests.routing.bgp import ( VerifyBGPAdvCommunities, VerifyBGPExchangedRoutes, @@ -24,556 +27,397 @@ VerifyBGPSpecificPeers, VerifyBGPTimers, VerifyEVPNType2Route, + _check_bgp_neighbor_capability, ) from tests.units.anta_tests import test + +@pytest.mark.parametrize( + ("input_dict", "expected"), + [ + pytest.param({"advertised": True, "received": True, "enabled": True}, True, id="all True"), + pytest.param({"advertised": False, "received": True, "enabled": True}, False, id="advertised False"), + pytest.param({"advertised": True, "received": False, "enabled": True}, False, id="received False"), + pytest.param({"advertised": True, "received": True, "enabled": False}, False, id="enabled False"), + pytest.param({"advertised": True, "received": True}, False, id="missing enabled"), + pytest.param({}, False), + ], +) +def test_check_bgp_neighbor_capability(input_dict: dict[str, bool], expected: bool) -> None: + """Test check_bgp_neighbor_capability.""" + assert _check_bgp_neighbor_capability(input_dict) == expected + + DATA: list[dict[str, Any]] = [ { "name": "success", "test": VerifyBGPPeerCount, "eos_data": [ - # Need to order the output as the commands would be sorted after template rendering. { "vrfs": { "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "10.1.0.1": { + "peerState": "Idle", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "10.1.0.2": { + "peerState": "Idle", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, }, }, - }, - }, - { - "vrfs": { - "MGMT": { + "DEV": { + "vrf": "DEV", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.255.0.21": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, + "10.1.254.1": { + "peerState": "Idle", + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 4, "nlrisAccepted": 4}, + } }, }, - }, + } }, + ], + "inputs": { + "address_families": [ + {"afi": "evpn", "num_peers": 2}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}, + {"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "num_peers": 1}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "success-peer-state-check-true", + "test": VerifyBGPPeerCount, + "eos_data": [ { "vrfs": { "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.255.0.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.0.1": { "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - "10.255.0.2": { - "description": "DC1-SPINE2_Ethernet1", - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.0.2": { "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.255.0.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.254.1": { "peerState": "Established", + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 17}, }, - "10.255.0.12": { - "description": "DC1-SPINE2_Ethernet1", - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.0": { + "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, + }, + "10.1.255.2": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, }, }, - }, - }, - { - "vrfs": { - "default": { + "DEV": { + "vrf": "DEV", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.255.0.21": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.255.0.22": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.254.1": { "peerState": "Established", - }, + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 4, "nlrisAccepted": 4}, + } }, }, - }, + } }, ], "inputs": { "address_families": [ - # evpn first to make sure that the correct mapping output to input is kept. - {"afi": "evpn", "num_peers": 2}, - {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 1}, - {"afi": "link-state", "num_peers": 2}, - {"afi": "path-selection", "num_peers": 2}, + {"afi": "evpn", "num_peers": 2, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "num_peers": 1, "check_peer_state": True}, ] }, "expected": {"result": "success"}, }, { - "name": "failure-wrong-count", + "name": "failure-vrf-not-configured", "test": VerifyBGPPeerCount, "eos_data": [ { "vrfs": { "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - }, - }, - }, - { - "vrfs": { - "MGMT": { - "peers": { - "10.255.0.21": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.0.1": { "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.255.0.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.0.2": { "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - "10.255.0.2": { - "description": "DC1-SPINE2_Ethernet1", - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.254.1": { "peerState": "Established", + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 17}, }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.255.0.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.0": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, - "10.255.0.12": { - "description": "DC1-SPINE2_Ethernet1", - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.2": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, }, }, - }, - }, - { - "vrfs": { - "default": { + "DEV": { + "vrf": "DEV", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.255.0.21": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.254.1": { "peerState": "Established", - }, + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 4, "nlrisAccepted": 4}, + } }, }, - }, - }, - ], - "inputs": { - "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 2}, - {"afi": "evpn", "num_peers": 1}, - {"afi": "link-state", "num_peers": 3}, - {"afi": "path-selection", "num_peers": 3}, - ] - }, - "expected": { - "result": "failure", - "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'Expected: 2, Actual: 1'}}, " - "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 1, Actual: 2'}}, " - "{'afi': 'link-state', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}, " - "{'afi': 'path-selection', 'vrfs': {'default': 'Expected: 3, Actual: 1'}}]" - ], - }, - }, - { - "name": "failure-no-peers", - "test": VerifyBGPPeerCount, - "eos_data": [ - { - "vrfs": { - "default": { - "peers": {}, - } - } - }, - { - "vrfs": { - "MGMT": { - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "peers": {}, - } } }, ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 1}, - {"afi": "evpn", "num_peers": 2}, - {"afi": "link-state", "num_peers": 2}, - {"afi": "path-selection", "num_peers": 2}, - ] - }, - "expected": { - "result": "failure", - "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 2, Actual: 0'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'Expected: 1, Actual: 0'}}, " - "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 2, Actual: 0'}}, " - "{'afi': 'link-state', 'vrfs': {'default': 'Expected: 2, Actual: 0'}}, " - "{'afi': 'path-selection', 'vrfs': {'default': 'Expected: 2, Actual: 0'}}]" - ], - }, - }, - { - "name": "failure-not-configured", - "test": VerifyBGPPeerCount, - "eos_data": [{"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}], - "inputs": { - "address_families": [ - {"afi": "ipv6", "safi": "multicast", "vrf": "DEV", "num_peers": 3}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 1}, - {"afi": "evpn", "num_peers": 2}, - {"afi": "link-state", "num_peers": 2}, - {"afi": "path-selection", "num_peers": 2}, + {"afi": "evpn", "num_peers": 2, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 2, "check_peer_state": True}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv6', 'safi': 'multicast', 'vrfs': {'DEV': 'Not Configured'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'Not Configured'}}, " - "{'afi': 'evpn', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'link-state', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'path-selection', 'vrfs': {'default': 'Not Configured'}}]" + "AFI: ipv4 SAFI: unicast VRF: PROD - VRF not configured", ], }, }, { - "name": "success-vrf-all", + "name": "failure-peer-state-check-true", "test": VerifyBGPPeerCount, "eos_data": [ { "vrfs": { "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.0.1": { "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, + }, + "10.1.0.2": { + "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - }, - }, - "PROD": { - "peers": { "10.1.254.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, "peerState": "Established", + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 17}, }, - "192.168.1.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.0": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.2": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, }, }, - "PROD": { + "DEV": { + "vrf": "DEV", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.254.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.254.1": { "peerState": "Established", - }, + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 4, "nlrisAccepted": 4}, + } }, }, - }, + } }, ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 3}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "all", "num_peers": 2}, + {"afi": "evpn", "num_peers": 2, "check_peer_state": True}, + {"afi": "vpn-ipv4", "num_peers": 2, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "num_peers": 1, "check_peer_state": True}, ] }, - "expected": {"result": "success"}, + "expected": { + "result": "failure", + "messages": [ + "AFI: vpn-ipv4 - Expected: 2, Actual: 0", + ], + }, }, { - "name": "failure-vrf-all", + "name": "failure-wrong-count-peer-state-check-true", "test": VerifyBGPPeerCount, "eos_data": [ { "vrfs": { "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.0.1": { "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, + }, + "10.1.0.2": { + "peerState": "Established", + "peerAsn": "65100", + "ipv4MplsVpn": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, - }, - }, - "PROD": { - "peers": { "10.1.254.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, "peerState": "Established", + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 17, "nlrisAccepted": 17}, }, - "192.168.1.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.0": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, + "10.1.255.2": { "peerState": "Established", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 14, "nlrisAccepted": 14}, }, }, }, - "PROD": { + "DEV": { + "vrf": "DEV", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { "10.1.254.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "192.168.1.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, "peerState": "Established", - }, + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 4, "nlrisAccepted": 4}, + } }, }, - }, + } }, ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 5}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "all", "num_peers": 2}, + {"afi": "evpn", "num_peers": 3, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3, "check_peer_state": True}, + {"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "num_peers": 2, "check_peer_state": True}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'all': 'Expected: 5, Actual: 3'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'all': 'Expected: 2, Actual: 3'}}]" + "AFI: evpn - Expected: 3, Actual: 2", + "AFI: ipv4 SAFI: unicast VRF: DEV - Expected: 2, Actual: 1", ], }, }, { - "name": "failure-multiple-afi", + "name": "failure-wrong-count", "test": VerifyBGPPeerCount, "eos_data": [ { "vrfs": { - "PROD": { + "default": { + "vrf": "default", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.254.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "192.168.1.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - }, - }, - }, - {"vrfs": {}}, - { - "vrfs": { - "MGMT": { - "peers": { - "10.1.254.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "192.168.1.21": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.0.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.0.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - }, - }, - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.0.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.0.21": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "10.1.0.1": { + "peerState": "Idle", + "peerAsn": "65100", + "ipv4Unicast": {"afiSafiState": "advertised", "nlrisReceived": 0, "nlrisAccepted": 0}, + "l2VpnEvpn": {"afiSafiState": "negotiated", "nlrisReceived": 42, "nlrisAccepted": 42}, }, }, }, - }, - }, - { - "vrfs": { - "default": { + "DEV": { + "vrf": "DEV", + "routerId": "10.1.0.3", + "asn": "65120", "peers": { - "10.1.0.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.0.22": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, + "10.1.254.1": { + "peerState": "Idle", + "peerAsn": "65120", + "ipv4Unicast": {"afiSafiState": "negotiated", "nlrisReceived": 4, "nlrisAccepted": 4}, + } }, }, - }, + } }, ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 3}, - {"afi": "ipv6", "safi": "unicast", "vrf": "default", "num_peers": 3}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT", "num_peers": 3}, - {"afi": "evpn", "num_peers": 3}, - {"afi": "link-state", "num_peers": 4}, - {"afi": "path-selection", "num_peers": 1}, - ], + {"afi": "evpn", "num_peers": 2}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}, + {"afi": "ipv4", "safi": "unicast", "vrf": "DEV", "num_peers": 2}, + ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 3, Actual: 2'}}, " - "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'Expected: 3, Actual: 2'}}, " - "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}, " - "{'afi': 'link-state', 'vrfs': {'default': 'Expected: 4, Actual: 2'}}, " - "{'afi': 'path-selection', 'vrfs': {'default': 'Expected: 1, Actual: 2'}}]", + "AFI: evpn - Expected: 2, Actual: 1", + "AFI: ipv4 SAFI: unicast VRF: default - Expected: 2, Actual: 1", + "AFI: ipv4 SAFI: unicast VRF: DEV - Expected: 2, Actual: 1", ], }, }, @@ -584,163 +428,131 @@ { "vrfs": { "default": { - "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { - "MGMT": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.20": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.13", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - "10.1.255.22": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + ] + }, + "DEV": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - } + ] + }, } - }, + } + ], + "inputs": { + "address_families": [ + {"afi": "evpn"}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default"}, + {"afi": "ipv4", "safi": "unicast", "vrf": "DEV"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-vrf-not-configured", + "test": VerifyBGPPeersHealth, + "eos_data": [ { - "vrfs": { - "default": { - "peers": { - "10.1.255.30": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.32": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, + "vrfs": {}, + } ], "inputs": { "address_families": [ - # Path selection first to make sure input to output mapping is correct. + {"afi": "ipv4", "safi": "unicast", "vrf": "default"}, + {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"}, {"afi": "path-selection"}, + {"afi": "link-state"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "AFI: ipv4 SAFI: unicast VRF: default - VRF not configured", + "AFI: ipv4 SAFI: sr-te VRF: MGMT - VRF not configured", + "AFI: path-selection - VRF not configured", + "AFI: link-state - VRF not configured", + ], + }, + }, + { + "name": "failure-peer-not-found", + "test": VerifyBGPPeersHealth, + "eos_data": [{"vrfs": {"default": {"peerList": []}, "MGMT": {"peerList": []}}}], + "inputs": { + "address_families": [ {"afi": "ipv4", "safi": "unicast", "vrf": "default"}, {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"}, + {"afi": "path-selection"}, {"afi": "link-state"}, ] }, - "expected": {"result": "success"}, + "expected": { + "result": "failure", + "messages": [ + "AFI: ipv4 SAFI: unicast VRF: default - No peers found", + "AFI: ipv4 SAFI: sr-te VRF: MGMT - No peers found", + "AFI: path-selection - No peers found", + "AFI: link-state - No peers found", + ], + }, }, { - "name": "failure-issues", + "name": "failure-session-not-established", "test": VerifyBGPPeersHealth, "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", - }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { - "MGMT": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", - }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.20": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - "10.1.255.22": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.13", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"dps": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.30": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.14", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"linkState": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - "10.1.255.32": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4SrTe": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - } + ] + }, } - }, + } ], "inputs": { "address_families": [ @@ -753,559 +565,421 @@ "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': {'10.1.255.12': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}, " - "{'afi': 'path-selection', 'vrfs': {'default': {'10.1.255.20': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}, " - "{'afi': 'link-state', 'vrfs': {'default': {'10.1.255.32': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]" + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: path-selection Peer: 10.100.0.13 - Session state is not established - State: Idle", + "AFI: link-state Peer: 10.100.0.14 - Session state is not established - State: Idle", ], }, }, { - "name": "success-vrf-all", + "name": "failure-afi-not-negotiated", "test": VerifyBGPPeersHealth, "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": False, "received": False, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.13", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"dps": {"advertised": True, "received": False, "enabled": False}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - }, - "PROD": { - "peers": { - "10.1.254.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.14", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"linkState": {"advertised": False, "received": False, "enabled": False}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - "192.168.1.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4SrTe": {"advertised": False, "received": False, "enabled": False}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, + ] }, } - }, + } + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "vrf": "default"}, + {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"}, + {"afi": "path-selection"}, + {"afi": "link-state"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - AFI/SAFI state is not negotiated - Advertised: False, Received: False, Enabled: True", + "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - AFI/SAFI state is not negotiated - Advertised: False, Received: False, Enabled: False", + "AFI: path-selection Peer: 10.100.0.13 - Session state is not established - State: Idle", + "AFI: path-selection Peer: 10.100.0.13 - AFI/SAFI state is not negotiated - Advertised: True, Received: False, Enabled: False", + "AFI: link-state Peer: 10.100.0.14 - Session state is not established - State: Idle", + "AFI: link-state Peer: 10.100.0.14 - AFI/SAFI state is not negotiated - Advertised: False, Received: False, Enabled: False", + ], + }, + }, + { + "name": "failure-tcp-queues", + "test": VerifyBGPPeersHealth, + "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 4, "inputQueueLength": 2}, }, - "10.1.255.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.13", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"dps": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 1, "inputQueueLength": 1}, }, - }, - }, - "PROD": { - "peers": { - "10.1.254.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.14", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"linkState": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 2, "inputQueueLength": 3}, }, - "192.168.1.111": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + ] + }, + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4SrTe": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 1, "inputQueueLength": 5}, }, - }, + ] }, } - }, + } ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "all"}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "all"}, + {"afi": "ipv4", "safi": "unicast", "vrf": "default"}, + {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"}, + {"afi": "path-selection"}, + {"afi": "link-state"}, ] }, "expected": { - "result": "success", + "result": "failure", + "messages": [ + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 2, OutQ: 4", + "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: ipv4 SAFI: sr-te VRF: MGMT Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 5, OutQ: 1", + "AFI: path-selection Peer: 10.100.0.13 - Session state is not established - State: Idle", + "AFI: path-selection Peer: 10.100.0.13 - Session has non-empty message queues - InQ: 1, OutQ: 1", + "AFI: link-state Peer: 10.100.0.14 - Session state is not established - State: Idle", + "AFI: link-state Peer: 10.100.0.14 - Session has non-empty message queues - InQ: 3, OutQ: 2", + ], }, }, { - "name": "failure-issues-vrf-all", - "test": VerifyBGPPeersHealth, + "name": "success", + "test": VerifyBGPSpecificPeers, "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + { + "peerAddress": "10.100.0.13", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, + ] }, - "PROD": { - "peers": { - "10.1.254.1": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "192.168.1.11": { - "inMsgQueue": 100, - "outMsgQueue": 200, - "peerState": "Established", + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.14", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, + ] }, } - }, + } + ], + "inputs": { + "address_families": [ + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "evpn", "peers": ["10.100.0.13"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-peer-not-configured", + "test": VerifyBGPSpecificPeers, + "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", - }, - "10.1.255.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, + "peerList": [ + { + "peerAddress": "10.100.0.20", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, + } + ] }, - "PROD": { - "peers": { - "10.1.254.11": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "192.168.1.111": { - "inMsgQueue": 100, - "outMsgQueue": 200, - "peerState": "Established", + "MGMT": { + "peerList": [ + { + "peerAddress": "10.100.0.10", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, + ] }, } - }, + } ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "all"}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "all"}, + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "evpn", "peers": ["10.100.0.13"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}, " - "'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 100, 'outMsgQueue': 200}}}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'default': {'10.1.255.10': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}, " - "'PROD': {'192.168.1.111': {'peerState': 'Established', 'inMsgQueue': 100, 'outMsgQueue': 200}}}}]" + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Not configured", + "AFI: evpn Peer: 10.100.0.13 - Not configured", + "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Not configured", ], }, }, { - "name": "failure-not-configured", - "test": VerifyBGPPeersHealth, - "eos_data": [{"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}], + "name": "failure-vrf-not-configured", + "test": VerifyBGPSpecificPeers, + "eos_data": [ + { + "vrfs": {}, + } + ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "unicast", "vrf": "DEV"}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"}, - {"afi": "link-state"}, - {"afi": "path-selection"}, + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "evpn", "peers": ["10.100.0.13"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'DEV': 'Not Configured'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'Not Configured'}}, " - "{'afi': 'link-state', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'path-selection', 'vrfs': {'default': 'Not Configured'}}]" + "AFI: ipv4 SAFI: unicast VRF: default - VRF not configured", + "AFI: evpn - VRF not configured", + "AFI: ipv4 SAFI: unicast VRF: MGMT - VRF not configured", ], }, }, { - "name": "failure-no-peers", - "test": VerifyBGPPeersHealth, + "name": "failure-session-not-established", + "test": VerifyBGPSpecificPeers, "eos_data": [ { "vrfs": { "default": { - "vrf": "default", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } - } - }, - { - "vrfs": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, + } + ] + }, "MGMT": { - "vrf": "MGMT", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "vrf": "default", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "vrf": "default", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } + "peerList": [ + { + "peerAddress": "10.100.0.14", + "state": "Idle", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, + }, + ] + }, } - }, + } ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "multicast"}, - {"afi": "ipv4", "safi": "sr-te", "vrf": "MGMT"}, - {"afi": "link-state"}, - {"afi": "path-selection"}, + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'default': 'No Peers'}}, {'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'No Peers'}}, " - "{'afi': 'link-state', 'vrfs': {'default': 'No Peers'}}, {'afi': 'path-selection', 'vrfs': {'default': 'No Peers'}}]" + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session state is not established - State: Idle", + "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Session state is not established - State: Idle", ], }, }, { - "name": "success", + "name": "failure-afi-safi-not-negotiated", "test": VerifyBGPSpecificPeers, "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": False, "received": False, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, + } + ] + }, "MGMT": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.20": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.22": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.30": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.32": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", + "peerList": [ + { + "peerAddress": "10.100.0.14", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": False, "received": False, "enabled": False}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - } + ] + }, } - }, + } ], "inputs": { "address_families": [ - # Path selection first to make sure input to output mapping is correct. - {"afi": "path-selection", "peers": ["10.1.255.20", "10.1.255.22"]}, - { - "afi": "ipv4", - "safi": "unicast", - "vrf": "default", - "peers": ["10.1.255.0", "10.1.255.2"], - }, - { - "afi": "ipv4", - "safi": "sr-te", - "vrf": "MGMT", - "peers": ["10.1.255.10", "10.1.255.12"], - }, - {"afi": "link-state", "peers": ["10.1.255.30", "10.1.255.32"]}, + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, ] }, - "expected": {"result": "success"}, + "expected": { + "result": "failure", + "messages": [ + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - AFI/SAFI state is not negotiated - Advertised: False, Received: False, Enabled: True", + "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - AFI/SAFI state is not negotiated - Advertised: False, Received: False, Enabled: False", + ], + }, }, { - "name": "failure-issues", + "name": "failure-afi-safi-not-correct", "test": VerifyBGPSpecificPeers, "eos_data": [ { "vrfs": { "default": { - "peers": { - "10.1.255.0": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", - }, - "10.1.255.2": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": False, "received": False, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, + } + ] + }, "MGMT": { - "peers": { - "10.1.255.10": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.12": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", - }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.20": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", - }, - "10.1.255.22": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - }, - } - } - }, - { - "vrfs": { - "default": { - "peers": { - "10.1.255.30": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Established", - }, - "10.1.255.32": { - "inMsgQueue": 0, - "outMsgQueue": 0, - "peerState": "Idle", + "peerList": [ + { + "peerAddress": "10.100.0.14", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"l2VpnEvpn": {"advertised": False, "received": False, "enabled": False}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 0, "inputQueueLength": 0}, }, - }, - } + ] + }, } - }, + } ], "inputs": { "address_families": [ - { - "afi": "ipv4", - "safi": "unicast", - "vrf": "default", - "peers": ["10.1.255.0", "10.1.255.2"], - }, - { - "afi": "ipv4", - "safi": "sr-te", - "vrf": "MGMT", - "peers": ["10.1.255.10", "10.1.255.12"], - }, - {"afi": "path-selection", "peers": ["10.1.255.20", "10.1.255.22"]}, - {"afi": "link-state", "peers": ["10.1.255.30", "10.1.255.32"]}, - ] - }, - "expected": { - "result": "failure", - "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': {'10.1.255.12': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}, " - "{'afi': 'path-selection', 'vrfs': {'default': {'10.1.255.20': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}, " - "{'afi': 'link-state', 'vrfs': {'default': {'10.1.255.32': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]" - ], - }, - }, - { - "name": "failure-not-configured", - "test": VerifyBGPSpecificPeers, - "eos_data": [{"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}, {"vrfs": {}}], - "inputs": { - "address_families": [ - { - "afi": "ipv4", - "safi": "unicast", - "vrf": "DEV", - "peers": ["10.1.255.0"], - }, - { - "afi": "ipv4", - "safi": "sr-te", - "vrf": "MGMT", - "peers": ["10.1.255.10"], - }, - {"afi": "link-state", "peers": ["10.1.255.20"]}, - {"afi": "path-selection", "peers": ["10.1.255.30"]}, + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'DEV': 'Not Configured'}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': 'Not Configured'}}, {'afi': 'link-state', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'path-selection', 'vrfs': {'default': 'Not Configured'}}]" + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - AFI/SAFI state is not negotiated", + "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - AFI/SAFI state is not negotiated", ], }, }, { - "name": "failure-no-peers", + "name": "failure-tcp-queues", "test": VerifyBGPSpecificPeers, "eos_data": [ { "vrfs": { "default": { - "vrf": "default", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } - } - }, - { - "vrfs": { + "peerList": [ + { + "peerAddress": "10.100.0.12", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 3, "inputQueueLength": 3}, + } + ] + }, "MGMT": { - "vrf": "MGMT", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "vrf": "default", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } - } - }, - { - "vrfs": { - "default": { - "vrf": "default", - "routerId": "10.1.0.3", - "asn": "65120", - "peers": {}, - } + "peerList": [ + { + "peerAddress": "10.100.0.14", + "state": "Established", + "neighborCapabilities": {"multiprotocolCaps": {"ipv4Unicast": {"advertised": True, "received": True, "enabled": True}}}, + "peerTcpInfo": {"state": "ESTABLISHED", "outputQueueLength": 2, "inputQueueLength": 2}, + }, + ] + }, } - }, + } ], "inputs": { "address_families": [ - {"afi": "ipv4", "safi": "multicast", "peers": ["10.1.255.0"]}, - { - "afi": "ipv4", - "safi": "sr-te", - "vrf": "MGMT", - "peers": ["10.1.255.10"], - }, - {"afi": "link-state", "peers": ["10.1.255.20"]}, - {"afi": "path-selection", "peers": ["10.1.255.30"]}, + {"afi": "ipv4", "safi": "unicast", "peers": ["10.100.0.12"]}, + {"afi": "ipv4", "safi": "unicast", "vrf": "MGMT", "peers": ["10.100.0.14"]}, ] }, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'multicast', 'vrfs': {'default': {'10.1.255.0': {'peerNotFound': True}}}}, " - "{'afi': 'ipv4', 'safi': 'sr-te', 'vrfs': {'MGMT': {'10.1.255.10': {'peerNotFound': True}}}}, " - "{'afi': 'link-state', 'vrfs': {'default': {'10.1.255.20': {'peerNotFound': True}}}}, " - "{'afi': 'path-selection', 'vrfs': {'default': {'10.1.255.30': {'peerNotFound': True}}}}]" + "AFI: ipv4 SAFI: unicast VRF: default Peer: 10.100.0.12 - Session has non-empty message queues - InQ: 3, OutQ: 3", + "AFI: ipv4 SAFI: unicast VRF: MGMT Peer: 10.100.0.14 - Session has non-empty message queues - InQ: 2, OutQ: 2", ], }, }, diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index 9bd64656c..952e8388d 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -107,8 +107,8 @@ "expected": { "result": "failure", "messages": [ - "Following BFD peers are not configured or timers are not correct:\n" - "{'192.0.255.7': {'CS': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}" + "Peer: 192.0.255.7 VRF: CS - Not found", + "Peer: 192.0.255.70 VRF: MGMT - Not found", ], }, }, @@ -160,9 +160,11 @@ "expected": { "result": "failure", "messages": [ - "Following BFD peers are not configured or timers are not correct:\n" - "{'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}}}" + "Peer: 192.0.255.7 VRF: default - Incorrect Transmit interval - Expected: 1200 Actual: 1300", + "Peer: 192.0.255.7 VRF: default - Incorrect Multiplier - Expected: 3 Actual: 4", + "Peer: 192.0.255.70 VRF: MGMT - Incorrect Transmit interval - Expected: 1200 Actual: 120", + "Peer: 192.0.255.70 VRF: MGMT - Incorrect Receive interval - Expected: 1200 Actual: 120", + "Peer: 192.0.255.70 VRF: MGMT - Incorrect Multiplier - Expected: 3 Actual: 5", ], }, }, @@ -239,8 +241,8 @@ "expected": { "result": "failure", "messages": [ - "Following BFD peers are not configured, status is not up or remote disc is zero:\n" - "{'192.0.255.7': {'CS': 'Not Configured'}, '192.0.255.70': {'MGMT': 'Not Configured'}}" + "Peer: 192.0.255.7 VRF: CS - Not found", + "Peer: 192.0.255.70 VRF: MGMT - Not found", ], }, }, @@ -255,7 +257,7 @@ "192.0.255.7": { "peerStats": { "": { - "status": "Down", + "status": "down", "remoteDisc": 108328132, } } @@ -267,7 +269,7 @@ "192.0.255.70": { "peerStats": { "": { - "status": "Down", + "status": "down", "remoteDisc": 0, } } @@ -281,9 +283,8 @@ "expected": { "result": "failure", "messages": [ - "Following BFD peers are not configured, status is not up or remote disc is zero:\n" - "{'192.0.255.7': {'default': {'status': 'Down', 'remote_disc': 108328132}}, " - "'192.0.255.70': {'MGMT': {'status': 'Down', 'remote_disc': 0}}}" + "Peer: 192.0.255.7 VRF: default - Session not properly established - State: down Remote Discriminator: 108328132", + "Peer: 192.0.255.70 VRF: MGMT - Session not properly established - State: down Remote Discriminator: 0", ], }, }, @@ -414,7 +415,8 @@ "expected": { "result": "failure", "messages": [ - "Following BFD peers are not up:\n192.0.255.7 is down in default VRF with remote disc 0.\n192.0.255.71 is down in MGMT VRF with remote disc 0." + "Peer: 192.0.255.7 VRF: default - Session not properly established - State: down Remote Discriminator: 0", + "Peer: 192.0.255.71 VRF: MGMT - Session not properly established - State: down Remote Discriminator: 0", ], }, }, @@ -458,7 +460,10 @@ "inputs": {}, "expected": { "result": "failure", - "messages": ["Following BFD peers were down:\n192.0.255.7 in default VRF has remote disc 0.\n192.0.255.71 in default VRF has remote disc 0."], + "messages": [ + "Peer: 192.0.255.7 VRF: default - Session not properly established - State: up Remote Discriminator: 0", + "Peer: 192.0.255.71 VRF: default - Session not properly established - State: up Remote Discriminator: 0", + ], }, }, { @@ -512,8 +517,9 @@ "expected": { "result": "failure", "messages": [ - "Following BFD peers were down:\n192.0.255.7 in default VRF was down 3 hours ago.\n" - "192.0.255.71 in default VRF was down 3 hours ago.\n192.0.255.17 in default VRF was down 3 hours ago." + "Peer: 192.0.255.7 VRF: default - Session failure detected within the expected uptime threshold (3 hours ago)", + "Peer: 192.0.255.71 VRF: default - Session failure detected within the expected uptime threshold (3 hours ago)", + "Peer: 192.0.255.17 VRF: default - Session failure detected within the expected uptime threshold (3 hours ago)", ], }, }, @@ -609,15 +615,14 @@ "inputs": { "bfd_peers": [ {"peer_address": "192.0.255.7", "vrf": "default", "protocols": ["isis"]}, - {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["isis"]}, + {"peer_address": "192.0.255.70", "vrf": "MGMT", "protocols": ["isis", "ospf"]}, ] }, "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']}}" + "Peer: 192.0.255.7 VRF: default - `isis` routing protocol(s) not configured", + "Peer: 192.0.255.70 VRF: MGMT - `isis` `ospf` routing protocol(s) not configured", ], }, }, @@ -641,8 +646,8 @@ "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'}}" + "Peer: 192.0.255.7 VRF: default - Not found", + "Peer: 192.0.255.70 VRF: MGMT - Not found", ], }, }, diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index beeaae65c..eac3084d9 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -153,7 +153,7 @@ ], }, ], - "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('10.0.0.5', '10.0.0.11')]"]}, + "expected": {"result": "failure", "messages": ["Host 10.0.0.11 (src: 10.0.0.5, vrf: default, size: 100B, repeat: 2) - Unreachable"]}, }, { "name": "failure-interface", @@ -187,7 +187,7 @@ ], }, ], - "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.11')]"]}, + "expected": {"result": "failure", "messages": ["Host 10.0.0.11 (src: Management0, vrf: default, size: 100B, repeat: 2) - Unreachable"]}, }, { "name": "failure-size", @@ -209,17 +209,11 @@ ], }, ], - "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.1')]"]}, + "expected": {"result": "failure", "messages": ["Host 10.0.0.1 (src: Management0, vrf: default, size: 1501B, repeat: 5, df-bit: enabled) - Unreachable"]}, }, { "name": "success", "test": VerifyLLDPNeighbors, - "inputs": { - "neighbors": [ - {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, - {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ], - }, "eos_data": [ { "lldpNeighbors": { @@ -256,16 +250,17 @@ }, }, ], + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + ], + }, "expected": {"result": "success"}, }, { "name": "success-multiple-neighbors", "test": VerifyLLDPNeighbors, - "inputs": { - "neighbors": [ - {"port": "Ethernet1", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ], - }, "eos_data": [ { "lldpNeighbors": { @@ -298,17 +293,16 @@ }, }, ], + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + ], + }, "expected": {"result": "success"}, }, { "name": "failure-port-not-configured", "test": VerifyLLDPNeighbors, - "inputs": { - "neighbors": [ - {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, - {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ], - }, "eos_data": [ { "lldpNeighbors": { @@ -330,17 +324,17 @@ }, }, ], - "expected": {"result": "failure", "messages": ["Port(s) not configured:\n Ethernet2"]}, - }, - { - "name": "failure-no-neighbor", - "test": VerifyLLDPNeighbors, "inputs": { "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, ], }, + "expected": {"result": "failure", "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - Port not found"]}, + }, + { + "name": "failure-no-neighbor", + "test": VerifyLLDPNeighbors, "eos_data": [ { "lldpNeighbors": { @@ -363,17 +357,17 @@ }, }, ], - "expected": {"result": "failure", "messages": ["No LLDP neighbor(s) on port(s):\n Ethernet2"]}, - }, - { - "name": "failure-wrong-neighbor", - "test": VerifyLLDPNeighbors, "inputs": { "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, ], }, + "expected": {"result": "failure", "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - No LLDP neighbors"]}, + }, + { + "name": "failure-wrong-neighbor", + "test": VerifyLLDPNeighbors, "eos_data": [ { "lldpNeighbors": { @@ -410,18 +404,20 @@ }, }, ], - "expected": {"result": "failure", "messages": ["Wrong LLDP neighbor(s) on port(s):\n Ethernet2\n DC1-SPINE2_Ethernet2"]}, - }, - { - "name": "failure-multiple", - "test": VerifyLLDPNeighbors, "inputs": { "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - {"port": "Ethernet3", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"}, ], }, + "expected": { + "result": "failure", + "messages": ["Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE2/Ethernet2"], + }, + }, + { + "name": "failure-multiple", + "test": VerifyLLDPNeighbors, "eos_data": [ { "lldpNeighbors": { @@ -444,23 +440,25 @@ }, }, ], + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, + {"port": "Ethernet3", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"}, + ], + }, "expected": { "result": "failure", "messages": [ - "Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-SPINE1_Ethernet2\n" - "No LLDP neighbor(s) on port(s):\n Ethernet2\n" - "Port(s) not configured:\n Ethernet3" + "Port Ethernet1 (Neighbor: DC1-SPINE1, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE1/Ethernet2", + "Port Ethernet2 (Neighbor: DC1-SPINE2, Neighbor Port: Ethernet1) - No LLDP neighbors", + "Port Ethernet3 (Neighbor: DC1-SPINE3, Neighbor Port: Ethernet1) - Port not found", ], }, }, { "name": "failure-multiple-neighbors", "test": VerifyLLDPNeighbors, - "inputs": { - "neighbors": [ - {"port": "Ethernet1", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"}, - ], - }, "eos_data": [ { "lldpNeighbors": { @@ -493,6 +491,14 @@ }, }, ], - "expected": {"result": "failure", "messages": ["Wrong LLDP neighbor(s) on port(s):\n Ethernet1\n DC1-SPINE1_Ethernet1\n DC1-SPINE2_Ethernet1"]}, + "inputs": { + "neighbors": [ + {"port": "Ethernet1", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"}, + ], + }, + "expected": { + "result": "failure", + "messages": ["Port Ethernet1 (Neighbor: DC1-SPINE3, Neighbor Port: Ethernet1) - Wrong LLDP neighbors: DC1-SPINE1/Ethernet1, DC1-SPINE2/Ethernet1"], + }, }, ] diff --git a/tests/units/anta_tests/test_cvx.py b/tests/units/anta_tests/test_cvx.py new file mode 100644 index 000000000..0d4cec4ea --- /dev/null +++ b/tests/units/anta_tests/test_cvx.py @@ -0,0 +1,149 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Data for testing anta.tests.cvx.""" + +from __future__ import annotations + +from typing import Any + +from anta.tests.cvx import VerifyManagementCVX, VerifyMcsClientMounts +from tests.units.anta_tests import test + +DATA: list[dict[str, Any]] = [ + { + "name": "success", + "test": VerifyMcsClientMounts, + "eos_data": [{"mountStates": [{"path": "mcs/v1/toSwitch/28-99-3a-8f-93-7b", "type": "Mcs::DeviceConfigV1", "state": "mountStateMountComplete"}]}], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "success-haclient", + "test": VerifyMcsClientMounts, + "eos_data": [ + { + "mountStates": [ + {"path": "mcs/v1/apiCfgRedState", "type": "Mcs::ApiConfigRedundancyState", "state": "mountStateMountComplete"}, + {"path": "mcs/v1/toSwitch/00-1c-73-74-c0-8b", "type": "Mcs::DeviceConfigV1", "state": "mountStateMountComplete"}, + ] + }, + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "success-partial-non-mcs", + "test": VerifyMcsClientMounts, + "eos_data": [ + { + "mountStates": [ + {"path": "blah/blah/blah", "type": "blah::blah", "state": "mountStatePreservedUnmounted"}, + {"path": "mcs/v1/toSwitch/00-1c-73-74-c0-8b", "type": "Mcs::DeviceConfigV1", "state": "mountStateMountComplete"}, + ] + }, + ], + "inputs": None, + "expected": {"result": "success"}, + }, + { + "name": "failure-nomounts", + "test": VerifyMcsClientMounts, + "eos_data": [ + {"mountStates": []}, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["MCS Client mount states are not present"]}, + }, + { + "name": "failure-mountStatePreservedUnmounted", + "test": VerifyMcsClientMounts, + "eos_data": [{"mountStates": [{"path": "mcs/v1/toSwitch/28-99-3a-8f-93-7b", "type": "Mcs::DeviceConfigV1", "state": "mountStatePreservedUnmounted"}]}], + "inputs": None, + "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]}, + }, + { + "name": "failure-partial-haclient", + "test": VerifyMcsClientMounts, + "eos_data": [ + { + "mountStates": [ + {"path": "mcs/v1/apiCfgRedState", "type": "Mcs::ApiConfigRedundancyState", "state": "mountStateMountComplete"}, + {"path": "mcs/v1/toSwitch/00-1c-73-74-c0-8b", "type": "Mcs::DeviceConfigV1", "state": "mountStatePreservedUnmounted"}, + ] + }, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]}, + }, + { + "name": "failure-full-haclient", + "test": VerifyMcsClientMounts, + "eos_data": [ + { + "mountStates": [ + {"path": "blah/blah/blah", "type": "blah::blahState", "state": "mountStatePreservedUnmounted"}, + {"path": "mcs/v1/toSwitch/00-1c-73-74-c0-8b", "type": "Mcs::DeviceConfigV1", "state": "mountStatePreservedUnmounted"}, + ] + }, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]}, + }, + { + "name": "failure-non-mcs-client", + "test": VerifyMcsClientMounts, + "eos_data": [ + {"mountStates": [{"path": "blah/blah/blah", "type": "blah::blahState", "state": "mountStatePreservedUnmounted"}]}, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["MCS Client mount states are not present"]}, + }, + { + "name": "failure-partial-mcs-client", + "test": VerifyMcsClientMounts, + "eos_data": [ + { + "mountStates": [ + {"path": "blah/blah/blah", "type": "blah::blahState", "state": "mountStatePreservedUnmounted"}, + {"path": "blah/blah/blah", "type": "Mcs::DeviceConfigV1", "state": "mountStatePreservedUnmounted"}, + ] + }, + ], + "inputs": None, + "expected": {"result": "failure", "messages": ["MCS Client mount states are not valid: mountStatePreservedUnmounted"]}, + }, + { + "name": "success-enabled", + "test": VerifyManagementCVX, + "eos_data": [ + { + "clusterStatus": { + "enabled": True, + } + } + ], + "inputs": {"enabled": True}, + "expected": {"result": "success"}, + }, + { + "name": "success-disabled", + "test": VerifyManagementCVX, + "eos_data": [ + { + "clusterStatus": { + "enabled": False, + } + } + ], + "inputs": {"enabled": False}, + "expected": {"result": "success"}, + }, + { + "name": "failure", + "test": VerifyManagementCVX, + "eos_data": [{"clusterStatus": {}}], + "inputs": {"enabled": False}, + "expected": {"result": "failure", "messages": ["Management CVX status is not valid: None"]}, + }, +] diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py index ea8106e84..ac0530881 100644 --- a/tests/units/anta_tests/test_interfaces.py +++ b/tests/units/anta_tests/test_interfaces.py @@ -1108,7 +1108,7 @@ "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, "expected": { "result": "failure", - "messages": ["The following interface(s) are not configured: ['Ethernet8']"], + "messages": ["Ethernet8 - Not configured"], }, }, { @@ -1126,7 +1126,7 @@ "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, "expected": { "result": "failure", - "messages": ["The following interface(s) are not in the expected state: ['Ethernet8 is down/down'"], + "messages": ["Ethernet8 - Expected: up/up, Actual: down/down"], }, }, { @@ -1150,7 +1150,7 @@ }, "expected": { "result": "failure", - "messages": ["The following interface(s) are not in the expected state: ['Ethernet8 is up/down'"], + "messages": ["Ethernet8 - Expected: up/up, Actual: up/down"], }, }, { @@ -1166,7 +1166,7 @@ "inputs": {"interfaces": [{"name": "PortChannel100", "status": "up"}]}, "expected": { "result": "failure", - "messages": ["The following interface(s) are not in the expected state: ['Port-Channel100 is down/lowerLayerDown'"], + "messages": ["Port-Channel100 - Expected: up/up, Actual: down/lowerLayerDown"], }, }, { @@ -1190,7 +1190,38 @@ }, "expected": { "result": "failure", - "messages": ["The following interface(s) are not in the expected state: ['Ethernet2 is up/unknown'"], + "messages": [ + "Ethernet2 - Expected: up/down, Actual: up/unknown", + "Ethernet8 - Expected: up/up, Actual: up/down", + ], + }, + }, + { + "name": "failure-interface-status-down", + "test": VerifyInterfacesStatus, + "eos_data": [ + { + "interfaceDescriptions": { + "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"}, + "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "unknown"}, + "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, + } + } + ], + "inputs": { + "interfaces": [ + {"name": "Ethernet2", "status": "down"}, + {"name": "Ethernet8", "status": "down"}, + {"name": "Ethernet3", "status": "down"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Ethernet2 - Expected: down, Actual: up", + "Ethernet8 - Expected: down, Actual: up", + "Ethernet3 - Expected: down, Actual: up", + ], }, }, { diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py index 3f13dfc0b..639c5c685 100644 --- a/tests/units/anta_tests/test_services.py +++ b/tests/units/anta_tests/test_services.py @@ -59,30 +59,22 @@ "test": VerifyDNSServers, "eos_data": [ { - "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}], + "nameServerConfigs": [ + {"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, + {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}, + {"ipAddr": "fd12:3456:789a::1", "vrf": "default", "priority": 0}, + ], } ], "inputs": { - "dns_servers": [{"server_address": "10.14.0.1", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.11", "vrf": "MGMT", "priority": 1}] + "dns_servers": [ + {"server_address": "10.14.0.1", "vrf": "default", "priority": 0}, + {"server_address": "10.14.0.11", "vrf": "MGMT", "priority": 1}, + {"server_address": "fd12:3456:789a::1", "vrf": "default", "priority": 0}, + ] }, "expected": {"result": "success"}, }, - { - "name": "failure-dns-missing", - "test": VerifyDNSServers, - "eos_data": [ - { - "nameServerConfigs": [{"ipAddr": "10.14.0.1", "vrf": "default", "priority": 0}, {"ipAddr": "10.14.0.11", "vrf": "MGMT", "priority": 1}], - } - ], - "inputs": { - "dns_servers": [{"server_address": "10.14.0.10", "vrf": "default", "priority": 0}, {"server_address": "10.14.0.21", "vrf": "MGMT", "priority": 1}] - }, - "expected": { - "result": "failure", - "messages": ["DNS server `10.14.0.10` is not configured with any VRF.", "DNS server `10.14.0.21` is not configured with any VRF."], - }, - }, { "name": "failure-no-dns-found", "test": VerifyDNSServers, @@ -96,7 +88,7 @@ }, "expected": { "result": "failure", - "messages": ["DNS server `10.14.0.10` is not configured with any VRF.", "DNS server `10.14.0.21` is not configured with any VRF."], + "messages": ["Server 10.14.0.10 (VRF: default, Priority: 0) - Not configured", "Server 10.14.0.21 (VRF: MGMT, Priority: 1) - Not configured"], }, }, { @@ -117,9 +109,9 @@ "expected": { "result": "failure", "messages": [ - "For DNS server `10.14.0.1`, the expected priority is `0`, but `1` was found instead.", - "DNS server `10.14.0.11` is not configured with VRF `default`.", - "DNS server `10.14.0.110` is not configured with any VRF.", + "Server 10.14.0.1 (VRF: CS, Priority: 0) - Incorrect priority - Priority: 1", + "Server 10.14.0.11 (VRF: default, Priority: 0) - Not configured", + "Server 10.14.0.110 (VRF: MGMT, Priority: 0) - Not configured", ], }, }, diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py index 1eda8a1d5..f610a8e5b 100644 --- a/tests/units/anta_tests/test_system.py +++ b/tests/units/anta_tests/test_system.py @@ -346,6 +346,39 @@ }, "expected": {"result": "success"}, }, + { + "name": "success-ip-dns", + "test": VerifyNTPAssociations, + "eos_data": [ + { + "peers": { + "1.1.1.1 (1.ntp.networks.com)": { + "condition": "sys.peer", + "peerIpAddr": "1.1.1.1", + "stratumLevel": 1, + }, + "2.2.2.2 (2.ntp.networks.com)": { + "condition": "candidate", + "peerIpAddr": "2.2.2.2", + "stratumLevel": 2, + }, + "3.3.3.3 (3.ntp.networks.com)": { + "condition": "candidate", + "peerIpAddr": "3.3.3.3", + "stratumLevel": 2, + }, + } + } + ], + "inputs": { + "ntp_servers": [ + {"server_address": "1.1.1.1", "preferred": True, "stratum": 1}, + {"server_address": "2.2.2.2", "stratum": 2}, + {"server_address": "3.3.3.3", "stratum": 2}, + ] + }, + "expected": {"result": "success"}, + }, { "name": "failure", "test": VerifyNTPAssociations, @@ -380,9 +413,9 @@ "expected": { "result": "failure", "messages": [ - "For NTP peer 1.1.1.1:\nExpected `sys.peer` as the condition, but found `candidate` instead.\nExpected `1` as the stratum, but found `2` instead.\n" - "For NTP peer 2.2.2.2:\nExpected `candidate` as the condition, but found `sys.peer` instead.\n" - "For NTP peer 3.3.3.3:\nExpected `candidate` as the condition, but found `sys.peer` instead.\nExpected `2` as the stratum, but found `3` instead." + "1.1.1.1 (Preferred: True, Stratum: 1) - Bad association - Condition: candidate, Stratum: 2", + "2.2.2.2 (Preferred: False, Stratum: 2) - Bad association - Condition: sys.peer, Stratum: 2", + "3.3.3.3 (Preferred: False, Stratum: 2) - Bad association - Condition: sys.peer, Stratum: 3", ], }, }, @@ -399,7 +432,7 @@ }, "expected": { "result": "failure", - "messages": ["None of NTP peers are not configured."], + "messages": ["No NTP peers configured"], }, }, { @@ -430,7 +463,7 @@ }, "expected": { "result": "failure", - "messages": ["NTP peer 3.3.3.3 is not configured."], + "messages": ["3.3.3.3 (Preferred: False, Stratum: 1) - Not configured"], }, }, { @@ -457,8 +490,9 @@ "expected": { "result": "failure", "messages": [ - "For NTP peer 1.1.1.1:\nExpected `sys.peer` as the condition, but found `candidate` instead.\n" - "NTP peer 2.2.2.2 is not configured.\nNTP peer 3.3.3.3 is not configured." + "1.1.1.1 (Preferred: True, Stratum: 1) - Bad association - Condition: candidate, Stratum: 1", + "2.2.2.2 (Preferred: False, Stratum: 1) - Not configured", + "3.3.3.3 (Preferred: False, Stratum: 1) - Not configured", ], }, }, diff --git a/tests/units/cli/conftest.py b/tests/units/cli/conftest.py index e63e60eb2..71c23e9c3 100644 --- a/tests/units/cli/conftest.py +++ b/tests/units/cli/conftest.py @@ -39,6 +39,7 @@ errmsg="Invalid command", not_exec=[], ), + "show interfaces": {}, } MOCK_CLI_TEXT: dict[str, asynceapi.EapiCommandError | str] = { diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 6a2624c1e..817ab7830 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -76,6 +76,19 @@ def test_anta_nrfu_text(click_runner: CliRunner) -> None: assert "leaf1 :: VerifyEOSVersion :: SUCCESS" in result.output +def test_anta_nrfu_text_multiple_failures(click_runner: CliRunner) -> None: + """Test anta nrfu text with multiple failures, catalog is given via env.""" + result = click_runner.invoke(anta, ["nrfu", "text"], env={"ANTA_CATALOG": str(DATA_DIR / "test_catalog_double_failure.yml")}) + assert result.exit_code == ExitCode.OK + assert ( + """spine1 :: VerifyInterfacesSpeed :: FAILURE + Interface `Ethernet2` is not found. + Interface `Ethernet3` is not found. + Interface `Ethernet4` is not found.""" + in result.output + ) + + def test_anta_nrfu_json(click_runner: CliRunner) -> None: """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "json"]) diff --git a/tests/units/input_models/__init__.py b/tests/units/input_models/__init__.py new file mode 100644 index 000000000..62747a681 --- /dev/null +++ b/tests/units/input_models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Tests for anta.input_models module.""" diff --git a/tests/units/input_models/routing/__init__.py b/tests/units/input_models/routing/__init__.py new file mode 100644 index 000000000..b56adb5fe --- /dev/null +++ b/tests/units/input_models/routing/__init__.py @@ -0,0 +1,4 @@ +# 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 for anta.input_models.routing submodule.""" diff --git a/tests/units/input_models/routing/test_bgp.py b/tests/units/input_models/routing/test_bgp.py new file mode 100644 index 000000000..aabc8f293 --- /dev/null +++ b/tests/units/input_models/routing/test_bgp.py @@ -0,0 +1,98 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Tests for anta.input_models.routing.bgp.py.""" + +# pylint: disable=C0302 +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pydantic import ValidationError + +from anta.input_models.routing.bgp import BgpAddressFamily +from anta.tests.routing.bgp import VerifyBGPPeerCount, VerifyBGPSpecificPeers + +if TYPE_CHECKING: + from anta.custom_types import Afi, Safi + + +class TestBgpAddressFamily: + """Test anta.input_models.routing.bgp.BgpAddressFamily.""" + + @pytest.mark.parametrize( + ("afi", "safi", "vrf"), + [ + pytest.param("ipv4", "unicast", "MGMT", id="afi"), + pytest.param("evpn", None, "default", id="safi"), + pytest.param("ipv4", "unicast", "default", id="vrf"), + ], + ) + def test_valid(self, afi: Afi, safi: Safi, vrf: str) -> None: + """Test BgpAddressFamily valid inputs.""" + BgpAddressFamily(afi=afi, safi=safi, vrf=vrf) + + @pytest.mark.parametrize( + ("afi", "safi", "vrf"), + [ + pytest.param("ipv4", None, "default", id="afi"), + pytest.param("evpn", "multicast", "default", id="safi"), + pytest.param("evpn", None, "MGMT", id="vrf"), + ], + ) + def test_invalid(self, afi: Afi, safi: Safi, vrf: str) -> None: + """Test BgpAddressFamily invalid inputs.""" + with pytest.raises(ValidationError): + BgpAddressFamily(afi=afi, safi=safi, vrf=vrf) + + +class TestVerifyBGPPeerCountInput: + """Test anta.tests.routing.bgp.VerifyBGPPeerCount.Input.""" + + @pytest.mark.parametrize( + ("address_families"), + [ + pytest.param([{"afi": "evpn", "num_peers": 2}], id="valid"), + ], + ) + def test_valid(self, address_families: list[BgpAddressFamily]) -> None: + """Test VerifyBGPPeerCount.Input valid inputs.""" + VerifyBGPPeerCount.Input(address_families=address_families) + + @pytest.mark.parametrize( + ("address_families"), + [ + pytest.param([{"afi": "evpn", "num_peers": 0}], id="zero-peer"), + pytest.param([{"afi": "evpn"}], id="None"), + ], + ) + def test_invalid(self, address_families: list[BgpAddressFamily]) -> None: + """Test VerifyBGPPeerCount.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyBGPPeerCount.Input(address_families=address_families) + + +class TestVerifyBGPSpecificPeersInput: + """Test anta.tests.routing.bgp.VerifyBGPSpecificPeers.Input.""" + + @pytest.mark.parametrize( + ("address_families"), + [ + pytest.param([{"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]}], id="valid"), + ], + ) + def test_valid(self, address_families: list[BgpAddressFamily]) -> None: + """Test VerifyBGPSpecificPeers.Input valid inputs.""" + VerifyBGPSpecificPeers.Input(address_families=address_families) + + @pytest.mark.parametrize( + ("address_families"), + [ + pytest.param([{"afi": "evpn"}], id="None"), + ], + ) + def test_invalid(self, address_families: list[BgpAddressFamily]) -> None: + """Test VerifyBGPSpecificPeers.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifyBGPSpecificPeers.Input(address_families=address_families) diff --git a/tests/units/reporter/conftest.py b/tests/units/reporter/conftest.py index ae7d3dfea..d0eed3639 100644 --- a/tests/units/reporter/conftest.py +++ b/tests/units/reporter/conftest.py @@ -5,4 +5,4 @@ from tests.units.result_manager.conftest import list_result_factory, result_manager, result_manager_factory, test_result_factory -__all__ = ["result_manager", "result_manager_factory", "list_result_factory", "test_result_factory"] +__all__ = ["list_result_factory", "result_manager", "result_manager_factory", "test_result_factory"] diff --git a/tests/units/test_custom_types.py b/tests/units/test_custom_types.py index 697017105..95c52344a 100644 --- a/tests/units/test_custom_types.py +++ b/tests/units/test_custom_types.py @@ -192,8 +192,7 @@ def test_regexp_eos_blacklist_cmds(test_string: str, expected: bool) -> None: """Test REGEXP_EOS_BLACKLIST_CMDS.""" def matches_any_regex(string: str, regex_list: list[str]) -> bool: - """ - Check if a string matches at least one regular expression in a list. + """Check if a string matches at least one regular expression in a list. :param string: The string to check. :param regex_list: A list of regular expressions. diff --git a/tests/units/test_models.py b/tests/units/test_models.py index d604b4835..8b7c50f10 100644 --- a/tests/units/test_models.py +++ b/tests/units/test_models.py @@ -26,8 +26,6 @@ class FakeTest(AntaTest): """ANTA test that always succeed.""" - name = "FakeTest" - description = "ANTA test that always succeed" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -40,8 +38,6 @@ def test(self) -> None: class FakeTestWithFailedCommand(AntaTest): """ANTA test with a command that failed.""" - name = "FakeTestWithFailedCommand" - description = "ANTA test with a command that failed" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", errors=["failed command"])] @@ -54,8 +50,6 @@ def test(self) -> None: class FakeTestWithUnsupportedCommand(AntaTest): """ANTA test with an unsupported command.""" - name = "FakeTestWithUnsupportedCommand" - description = "ANTA test with an unsupported command" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand( @@ -73,8 +67,6 @@ def test(self) -> None: class FakeTestWithInput(AntaTest): """ANTA test with inputs that always succeed.""" - name = "FakeTestWithInput" - description = "ANTA test with inputs that always succeed" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -92,8 +84,6 @@ def test(self) -> None: class FakeTestWithTemplate(AntaTest): """ANTA test with template that always succeed.""" - name = "FakeTestWithTemplate" - description = "ANTA test with template that always succeed" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] @@ -115,8 +105,6 @@ def test(self) -> None: class FakeTestWithTemplateNoRender(AntaTest): """ANTA test with template that miss the render() method.""" - name = "FakeTestWithTemplateNoRender" - description = "ANTA test with template that miss the render() method" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] @@ -134,8 +122,6 @@ def test(self) -> None: class FakeTestWithTemplateBadRender1(AntaTest): """ANTA test with template that raises a AntaTemplateRenderError exception.""" - name = "FakeTestWithTemplateBadRender" - description = "ANTA test with template that raises a AntaTemplateRenderError exception" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] @@ -157,8 +143,6 @@ def test(self) -> None: class FakeTestWithTemplateBadRender2(AntaTest): """ANTA test with template that raises an arbitrary exception in render().""" - name = "FakeTestWithTemplateBadRender2" - description = "ANTA test with template that raises an arbitrary exception in render()" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] @@ -180,8 +164,6 @@ def test(self) -> None: class FakeTestWithTemplateBadRender3(AntaTest): """ANTA test with template that gives extra template parameters in render().""" - name = "FakeTestWithTemplateBadRender3" - description = "ANTA test with template that gives extra template parameters in render()" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] @@ -203,8 +185,6 @@ def test(self) -> None: class FakeTestWithTemplateBadTest(AntaTest): """ANTA test with template that tries to access an undefined template parameter in test().""" - name = "FakeTestWithTemplateBadTest" - description = "ANTA test with template that tries to access an undefined template parameter in test()" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] @@ -227,8 +207,6 @@ def test(self) -> None: class SkipOnPlatformTest(AntaTest): """ANTA test that is skipped.""" - name = "SkipOnPlatformTest" - description = "ANTA test that is skipped on a specific platform" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -242,8 +220,6 @@ def test(self) -> None: class UnSkipOnPlatformTest(AntaTest): """ANTA test that is skipped.""" - name = "UnSkipOnPlatformTest" - description = "ANTA test that is skipped on a specific platform" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -257,8 +233,6 @@ def test(self) -> None: class SkipOnPlatformTestWithInput(AntaTest): """ANTA test skipped on platforms but with Input.""" - name = "SkipOnPlatformTestWithInput" - description = "ANTA test skipped on platforms but with Input" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -277,8 +251,6 @@ def test(self) -> None: class DeprecatedTestWithoutNewTest(AntaTest): """ANTA test that is deprecated without new test.""" - name = "DeprecatedTestWitouthNewTest" - description = "ANTA test that is deprecated without new test" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -292,8 +264,6 @@ def test(self) -> None: class DeprecatedTestWithNewTest(AntaTest): """ANTA test that is deprecated with new test.""" - name = "DeprecatedTestWithNewTest" - description = "ANTA deprecated test with New Test" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -307,8 +277,6 @@ def test(self) -> None: class FakeTestWithMissingTest(AntaTest): """ANTA test with missing test() method implementation.""" - name = "FakeTestWithMissingTest" - description = "ANTA test with missing test() method implementation" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @@ -526,65 +494,61 @@ class TestAntaTest: def test__init_subclass__(self) -> None: """Test __init_subclass__.""" - with pytest.raises(NotImplementedError) as exec_info: + with pytest.raises(AttributeError) as exec_info: - class _WrongTestNoName(AntaTest): - """ANTA test that is missing a name.""" + class _WrongTestNoCategories(AntaTest): + """ANTA test that is missing categories.""" - description = "ANTA test that is missing a name" - categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @AntaTest.anta_test def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoName is missing required class attribute name" + assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoCategories is missing required class attribute(s): categories" - with pytest.raises(NotImplementedError) as exec_info: + with pytest.raises(AttributeError) as exec_info: - class _WrongTestNoDescription(AntaTest): - """ANTA test that is missing a description.""" + class _WrongTestNoCommands(AntaTest): + """ANTA test that is missing commands.""" - name = "WrongTestNoDescription" categories: ClassVar[list[str]] = [] - commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @AntaTest.anta_test def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoDescription is missing required class attribute description" + assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoCommands is missing required class attribute(s): commands" - with pytest.raises(NotImplementedError) as exec_info: + with pytest.raises( + AttributeError, + match="Cannot set the description for class _WrongTestNoDescription, either set it in the class definition or add a docstring to the class.", + ): - class _WrongTestNoCategories(AntaTest): - """ANTA test that is missing categories.""" + class _WrongTestNoDescription(AntaTest): + # ANTA test that is missing a description and does not have a doctstring. - name = "WrongTestNoCategories" - description = "ANTA test that is missing categories" commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] + categories: ClassVar[list[str]] = [] @AntaTest.anta_test def test(self) -> None: self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoCategories is missing required class attribute categories" - - with pytest.raises(NotImplementedError) as exec_info: + class _TestOverwriteNameAndDescription(AntaTest): + """ANTA test where both the test name and description are overwritten in the class definition.""" - class _WrongTestNoCommands(AntaTest): - """ANTA test that is missing commands.""" - - name = "WrongTestNoCommands" - description = "ANTA test that is missing commands" - categories: ClassVar[list[str]] = [] + name: ClassVar[str] = "CustomName" + description: ClassVar[str] = "Custom description" + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] + categories: ClassVar[list[str]] = [] - @AntaTest.anta_test - def test(self) -> None: - self.result.is_success() + @AntaTest.anta_test + def test(self) -> None: + self.result.is_success() - assert exec_info.value.args[0] == "Class tests.units.test_models._WrongTestNoCommands is missing required class attribute commands" + assert _TestOverwriteNameAndDescription.name == "CustomName" + assert _TestOverwriteNameAndDescription.description == "Custom description" def test_abc(self) -> None: """Test that an error is raised if AntaTest is not implemented.""" @@ -626,8 +590,6 @@ def test_blacklist(self, device: AntaDevice, command: str) -> None: class FakeTestWithBlacklist(AntaTest): """Fake Test for blacklist.""" - name = "FakeTestWithBlacklist" - description = "ANTA test that has blacklisted command" categories: ClassVar[list[str]] = [] commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command=command)] diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index b80259cc3..8d19a4d1a 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -138,6 +138,7 @@ def side_effect_setrlimit(resource_id: int, limits: tuple[int, int]) -> None: pytest.param({"filename": "test_inventory_with_tags.yml"}, None, {"VerifyMlagStatus", "VerifyUptime"}, 3, 5, id="filtered-tests"), pytest.param({"filename": "test_inventory_with_tags.yml"}, {"leaf"}, {"VerifyMlagStatus", "VerifyUptime"}, 2, 4, id="1-tag-filtered-tests"), pytest.param({"filename": "test_inventory_with_tags.yml"}, {"invalid"}, None, 0, 0, id="invalid-tag"), + pytest.param({"filename": "test_inventory_with_tags.yml"}, {"dc1"}, None, 0, 0, id="device-tag-no-tests"), ], indirect=["inventory"], ) diff --git a/tests/units/test_tools.py b/tests/units/test_tools.py index 16f044333..b1f96a50c 100644 --- a/tests/units/test_tools.py +++ b/tests/units/test_tools.py @@ -11,7 +11,7 @@ import pytest -from anta.tools import convert_categories, custom_division, get_dict_superset, get_failed_logs, get_item, get_value +from anta.tools import convert_categories, custom_division, format_data, get_dict_superset, get_failed_logs, get_item, get_value TEST_GET_FAILED_LOGS_DATA = [ {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"}, @@ -513,3 +513,17 @@ def test_convert_categories(test_input: list[str], expected_raise: AbstractConte """Test convert_categories.""" with expected_raise: assert convert_categories(test_input) == expected_result + + +@pytest.mark.parametrize( + ("input_data", "expected_output"), + [ + pytest.param({"advertised": True, "received": True, "enabled": True}, "Advertised: True, Received: True, Enabled: True", id="multiple entry, all True"), + pytest.param({"advertised": False, "received": False}, "Advertised: False, Received: False", id="multiple entry, all False"), + pytest.param({}, "", id="empty dict"), + pytest.param({"test": True}, "Test: True", id="single entry"), + ], +) +def test_format_data(input_data: dict[str, bool], expected_output: str) -> None: + """Test format_data.""" + assert format_data(input_data) == expected_output