diff --git a/anta/custom_types.py b/anta/custom_types.py index f3877459f..3af27db97 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -154,16 +154,38 @@ def validate_regex(value: str) -> str: ErrDisableReasons = Literal[ "acl", "arp-inspection", + "bgp-session-tracking", "bpduguard", + "dot1x", + "dot1x-coa", "dot1x-session-replace", + "evpn-sa-mh", + "fabric-link-failure", + "fabric-link-flap", "hitless-reload-down", + "lacp-no-portid", "lacp-rate-limit", + "license-enforce", "link-flap", + "mlagasu", + "mlagdualprimary", + "mlagissu", + "mlagmaintdown", "no-internal-vlan", + "out-of-voqs", "portchannelguard", + "portgroup-disabled", "portsec", + "speed-misconfigured", + "storm-control", + "stp-no-portid", + "stuck-queue", "tapagg", "uplink-failure-detection", + "xcvr-misconfigured", + "xcvr-overheat", + "xcvr-power-unsupported", + "xcvr-unsupported", ] ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)] Percent = Annotated[float, Field(ge=0.0, le=100.0)] diff --git a/anta/input_models/services.py b/anta/input_models/services.py index 9989dae1b..25d772e41 100644 --- a/anta/input_models/services.py +++ b/anta/input_models/services.py @@ -6,9 +6,13 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address +from typing import Any, Literal +from warnings import warn from pydantic import BaseModel, ConfigDict, Field +from anta.custom_types import ErrDisableReasons + class DnsServer(BaseModel): """Model for a DNS server configuration.""" @@ -29,3 +33,42 @@ def __str__(self) -> str: Server 10.0.0.1 (VRF: default, Priority: 1) """ return f"Server {self.server_address} (VRF: {self.vrf}, Priority: {self.priority})" + + +class ErrdisableRecovery(BaseModel): + """Model for the error disable recovery functionality.""" + + model_config = ConfigDict(extra="forbid") + reason: ErrDisableReasons + """Name of the error disable reason.""" + status: Literal["Enabled", "Disabled"] = "Enabled" + """Operational status of the reason. Defaults to 'Enabled'.""" + interval: int = Field(ge=30, le=86400) + """Timer interval of the reason in seconds.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the ErrdisableRecovery for reporting. + + Examples + -------- + Reason: acl Status: Enabled Interval: 300 + """ + return f"Reason: {self.reason} Status: {self.status} Interval: {self.interval}" + + +class ErrDisableReason(ErrdisableRecovery): # pragma: no cover + """Alias for the ErrdisableRecovery model to maintain backward compatibility. + + When initialised, it will emit a deprecation warning and call the ErrdisableRecovery model. + + TODO: Remove this class in ANTA v2.0.0. + """ + + def __init__(self, **data: Any) -> None: # noqa: ANN401 + """Initialize the ErrdisableRecovery class, emitting a depreciation warning.""" + warn( + message="ErrDisableReason model is deprecated and will be removed in ANTA v2.0.0. Use the ErrdisableRecovery model instead.", + category=DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) diff --git a/anta/tests/services.py b/anta/tests/services.py index 89a7f2636..8d8942160 100644 --- a/anta/tests/services.py +++ b/anta/tests/services.py @@ -9,12 +9,9 @@ # mypy: disable-error-code=attr-defined from typing import ClassVar -from pydantic import BaseModel - -from anta.custom_types import ErrDisableInterval, ErrDisableReasons -from anta.input_models.services import DnsServer +from anta.input_models.services import DnsServer, ErrDisableReason, ErrdisableRecovery from anta.models import AntaCommand, AntaTemplate, AntaTest -from anta.tools import get_dict_superset, get_failed_logs +from anta.tools import get_dict_superset, get_item class VerifyHostname(AntaTest): @@ -166,12 +163,24 @@ def test(self) -> None: class VerifyErrdisableRecovery(AntaTest): - """Verifies the errdisable recovery reason, status, and interval. + """Verifies the error disable recovery functionality. + + This test performs the following checks for each specified error disable reason: + + 1. Verifying if the specified error disable reason exists. + 2. Checking if the recovery timer status matches the expected enabled/disabled state. + 3. Validating that the timer interval matches the configured value. Expected Results ---------------- - * Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input. - * Failure: The test will fail if the errdisable recovery reason is not found, the status is not enabled, or the interval does not match the input. + * Success: The test will pass if: + - The specified error disable reason exists. + - The recovery timer status matches the expected state. + - The timer interval matches the configured value. + * Failure: The test will fail if: + - The specified error disable reason does not exist. + - The recovery timer status does not match the expected state. + - The timer interval does not match the configured value. Examples -------- @@ -181,8 +190,10 @@ class VerifyErrdisableRecovery(AntaTest): reasons: - reason: acl interval: 30 + status: Enabled - reason: bpduguard interval: 30 + status: Enabled ``` """ @@ -193,44 +204,35 @@ class VerifyErrdisableRecovery(AntaTest): class Input(AntaTest.Input): """Input model for the VerifyErrdisableRecovery test.""" - reasons: list[ErrDisableReason] + reasons: list[ErrdisableRecovery] """List of errdisable reasons.""" - - class ErrDisableReason(BaseModel): - """Model for an errdisable reason.""" - - reason: ErrDisableReasons - """Type or name of the errdisable reason.""" - interval: ErrDisableInterval - """Interval of the reason in seconds.""" + ErrDisableReason: ClassVar[type[ErrdisableRecovery]] = ErrDisableReason @AntaTest.anta_test def test(self) -> None: """Main test function for VerifyErrdisableRecovery.""" - command_output = self.instance_commands[0].text_output self.result.is_success() + + # Skip header and last empty line + command_output = self.instance_commands[0].text_output.split("\n")[2:-1] + + # Collecting the actual errdisable reasons for faster lookup + errdisable_reasons = [ + {"reason": reason, "status": status, "interval": interval} + for line in command_output + if line.strip() # Skip empty lines + for reason, status, interval in [line.split(None, 2)] # Unpack split result + ] + for error_reason in self.inputs.reasons: - input_reason = error_reason.reason - input_interval = error_reason.interval - reason_found = False - - # Skip header and last empty line - lines = command_output.split("\n")[2:-1] - for line in lines: - # Skip empty lines - if not line.strip(): - continue - # Split by first two whitespaces - reason, status, interval = line.split(None, 2) - if reason != input_reason: - continue - reason_found = True - actual_reason_data = {"interval": interval, "status": status} - expected_reason_data = {"interval": str(input_interval), "status": "Enabled"} - if actual_reason_data != expected_reason_data: - failed_log = get_failed_logs(expected_reason_data, actual_reason_data) - self.result.is_failure(f"`{input_reason}`:{failed_log}\n") - break - - if not reason_found: - self.result.is_failure(f"`{input_reason}`: Not found.\n") + if not (reason_output := get_item(errdisable_reasons, "reason", error_reason.reason)): + self.result.is_failure(f"{error_reason} - Not found") + continue + + if not all( + [ + error_reason.status == (act_status := reason_output["status"]), + error_reason.interval == (act_interval := int(reason_output["interval"])), + ] + ): + self.result.is_failure(f"{error_reason} - Incorrect configuration - Status: {act_status} Interval: {act_interval}") diff --git a/docs/api/tests.services.md b/docs/api/tests.services.md index da8e1736b..674f5c081 100644 --- a/docs/api/tests.services.md +++ b/docs/api/tests.services.md @@ -33,4 +33,6 @@ anta_title: ANTA catalog for services tests merge_init_into_class: false anta_hide_test_module_description: true show_labels: true - filters: ["!^__str__"] + filters: + - "!^__init__" + - "!^__str__" diff --git a/examples/tests.yaml b/examples/tests.yaml index 1efe0d331..ce3b851a5 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -731,12 +731,14 @@ anta.tests.services: vrf: MGMT priority: 0 - VerifyErrdisableRecovery: - # Verifies the errdisable recovery reason, status, and interval. + # Verifies the error disable recovery functionality. reasons: - reason: acl interval: 30 + status: Enabled - reason: bpduguard interval: 30 + status: Enabled - VerifyHostname: # Verifies the hostname of a device. hostname: s1-spine1 diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py index 439b8ea4f..955aab0f0 100644 --- a/tests/units/anta_tests/test_services.py +++ b/tests/units/anta_tests/test_services.py @@ -147,7 +147,7 @@ "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}, {"reason": "tapagg", "interval": 30}]}, "expected": { "result": "failure", - "messages": ["`tapagg`: Not found."], + "messages": ["Reason: tapagg Status: Enabled Interval: 30 - Not found"], }, }, { @@ -165,7 +165,7 @@ "inputs": {"reasons": [{"reason": "acl", "interval": 300}, {"reason": "arp-inspection", "interval": 30}]}, "expected": { "result": "failure", - "messages": ["`acl`:\nExpected `Enabled` as the status, but found `Disabled` instead."], + "messages": ["Reason: acl Status: Enabled Interval: 300 - Incorrect configuration - Status: Disabled Interval: 300"], }, }, { @@ -183,7 +183,9 @@ "inputs": {"reasons": [{"reason": "acl", "interval": 30}, {"reason": "arp-inspection", "interval": 30}]}, "expected": { "result": "failure", - "messages": ["`acl`:\nExpected `30` as the interval, but found `300` instead."], + "messages": [ + "Reason: acl Status: Enabled Interval: 30 - Incorrect configuration - Status: Enabled Interval: 300", + ], }, }, { @@ -202,9 +204,9 @@ "expected": { "result": "failure", "messages": [ - "`acl`:\nExpected `30` as the interval, but found `300` instead.\nExpected `Enabled` as the status, but found `Disabled` instead.", - "`arp-inspection`:\nExpected `300` as the interval, but found `30` instead.", - "`tapagg`: Not found.", + "Reason: acl Status: Enabled Interval: 30 - Incorrect configuration - Status: Disabled Interval: 300", + "Reason: arp-inspection Status: Enabled Interval: 300 - Incorrect configuration - Status: Enabled Interval: 30", + "Reason: tapagg Status: Enabled Interval: 30 - Not found", ], }, },