Skip to content

Commit c9fae26

Browse files
authored
Merge branch 'main' into issue-427
2 parents de4be39 + f702c91 commit c9fae26

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+2340
-686
lines changed

.github/workflows/code-testing.yml

+17
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ jobs:
119119
run: pip install tox tox-gh-actions
120120
- name: "Run pytest via tox for ${{ matrix.python }}"
121121
run: tox
122+
test-python-windows:
123+
name: Pytest on 3.12 for windows
124+
runs-on: windows-2022
125+
needs: [lint-python, type-python]
126+
env:
127+
# Required to prevent asyncssh to fail.
128+
USERNAME: WindowsUser
129+
steps:
130+
- uses: actions/checkout@v4
131+
- name: Setup Python
132+
uses: actions/setup-python@v5
133+
with:
134+
python-version: 3.12
135+
- name: Install dependencies
136+
run: pip install tox tox-gh-actions
137+
- name: Run pytest via tox for 3.12 on Windows
138+
run: tox
122139
test-documentation:
123140
name: Build offline documentation for testing
124141
runs-on: ubuntu-20.04

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ repos:
4646
- '<!--| ~| -->'
4747

4848
- repo: https://github.com/astral-sh/ruff-pre-commit
49-
rev: v0.8.1
49+
rev: v0.8.3
5050
hooks:
5151
- id: ruff
5252
name: Run Ruff linter

anta/constants.py

+9
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,12 @@
1717
- [Summary Totals Per Category](#summary-totals-per-category)
1818
- [Test Results](#test-results)"""
1919
"""Table of Contents for the Markdown report."""
20+
21+
KNOWN_EOS_ERRORS = [
22+
r"BGP inactive",
23+
r"VRF '.*' is not active",
24+
r".* does not support IP",
25+
r"IS-IS (.*) is disabled because: .*",
26+
r"No source interface .*",
27+
]
28+
"""List of known EOS errors that should set a test status to 'failure' with the error message."""

anta/decorators.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
F = TypeVar("F", bound=Callable[..., Any])
1818

1919

20-
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]:
20+
# TODO: Remove this decorator in ANTA v2.0.0 in favor of deprecated_test_class
21+
def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: # pragma: no cover
2122
"""Return a decorator to log a message of WARNING severity when a test is deprecated.
2223
2324
Parameters
@@ -62,6 +63,57 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
6263
return decorator
6364

6465

66+
def deprecated_test_class(new_tests: list[str] | None = None, removal_in_version: str | None = None) -> Callable[[type[AntaTest]], type[AntaTest]]:
67+
"""Return a decorator to log a message of WARNING severity when a test is deprecated.
68+
69+
Parameters
70+
----------
71+
new_tests
72+
A list of new test classes that should replace the deprecated test.
73+
removal_in_version
74+
A string indicating the version in which the test will be removed.
75+
76+
Returns
77+
-------
78+
Callable[[type], type]
79+
A decorator that can be used to wrap test functions.
80+
81+
"""
82+
83+
def decorator(cls: type[AntaTest]) -> type[AntaTest]:
84+
"""Actual decorator that logs the message.
85+
86+
Parameters
87+
----------
88+
cls
89+
The cls to be decorated.
90+
91+
Returns
92+
-------
93+
cls
94+
The decorated cls.
95+
"""
96+
orig_init = cls.__init__
97+
98+
def new_init(*args: Any, **kwargs: Any) -> None:
99+
"""Overload __init__ to generate a warning message for deprecation."""
100+
if new_tests:
101+
new_test_names = ", ".join(new_tests)
102+
logger.warning("%s test is deprecated. Consider using the following new tests: %s.", cls.name, new_test_names)
103+
else:
104+
logger.warning("%s test is deprecated.", cls.name)
105+
orig_init(*args, **kwargs)
106+
107+
if removal_in_version is not None:
108+
cls.__removal_in_version = removal_in_version
109+
110+
# NOTE: we are ignoring mypy warning as we want to assign to a method here
111+
cls.__init__ = new_init # type: ignore[method-assign]
112+
return cls
113+
114+
return decorator
115+
116+
65117
def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]:
66118
"""Return a decorator to skip a test based on the device's hardware model.
67119

anta/device.py

+15-11
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ class AsyncEOSDevice(AntaDevice):
255255
256256
"""
257257

258-
def __init__(
258+
def __init__( # noqa: PLR0913
259259
self,
260260
host: str,
261261
username: str,
@@ -372,7 +372,7 @@ def _keys(self) -> tuple[Any, ...]:
372372
"""
373373
return (self._session.host, self._session.port)
374374

375-
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # noqa: C901 function is too complex - because of many required except blocks
375+
async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None:
376376
"""Collect device command output from EOS using aio-eapi.
377377
378378
Supports outformat `json` and `text` as output structure.
@@ -409,15 +409,7 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No
409409
command.output = response[-1]
410410
except asynceapi.EapiCommandError as e:
411411
# This block catches exceptions related to EOS issuing an error.
412-
command.errors = e.errors
413-
if command.requires_privileges:
414-
logger.error(
415-
"Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name
416-
)
417-
if command.supported:
418-
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors)
419-
else:
420-
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
412+
self._log_eapi_command_error(command, e)
421413
except TimeoutException as e:
422414
# This block catches Timeout exceptions.
423415
command.errors = [exc_to_str(e)]
@@ -446,6 +438,18 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No
446438
anta_log_exception(e, f"An error occurred while issuing an eAPI request to {self.name}", logger)
447439
logger.debug("%s: %s", self.name, command)
448440

441+
def _log_eapi_command_error(self, command: AntaCommand, e: asynceapi.EapiCommandError) -> None:
442+
"""Appropriately log the eapi command error."""
443+
command.errors = e.errors
444+
if command.requires_privileges:
445+
logger.error("Command '%s' requires privileged mode on %s. Verify user permissions and if the `enable` option is required.", command.command, self.name)
446+
if not command.supported:
447+
logger.debug("Command '%s' is not supported on '%s' (%s)", command.command, self.name, self.hw_model)
448+
elif command.returned_known_eos_error:
449+
logger.debug("Command '%s' returned a known error '%s': %s", command.command, self.name, command.errors)
450+
else:
451+
logger.error("Command '%s' failed on %s: %s", command.command, self.name, e.errors[0] if len(e.errors) == 1 else e.errors)
452+
449453
async def refresh(self) -> None:
450454
"""Update attributes of an AsyncEOSDevice instance.
451455

anta/input_models/avt.py

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright (c) 2023-2024 Arista Networks, Inc.
2+
# Use of this source code is governed by the Apache License 2.0
3+
# that can be found in the LICENSE file.
4+
"""Module containing input models for AVT tests."""
5+
6+
from __future__ import annotations
7+
8+
from ipaddress import IPv4Address
9+
10+
from pydantic import BaseModel, ConfigDict
11+
12+
13+
class AVTPath(BaseModel):
14+
"""AVT (Adaptive Virtual Topology) model representing path details and associated information."""
15+
16+
model_config = ConfigDict(extra="forbid")
17+
vrf: str = "default"
18+
"""VRF context. Defaults to `default`."""
19+
avt_name: str
20+
"""The name of the Adaptive Virtual Topology (AVT)."""
21+
destination: IPv4Address
22+
"""The IPv4 address of the destination peer in the AVT."""
23+
next_hop: IPv4Address
24+
"""The IPv4 address of the next hop used to reach the AVT peer."""
25+
path_type: str | None = None
26+
"""Specifies the type of path for the AVT. If not specified, both types 'direct' and 'multihop' are considered."""
27+
28+
def __str__(self) -> str:
29+
"""Return a human-readable string representation of the AVTPath for reporting.
30+
31+
Examples
32+
--------
33+
AVT CONTROL-PLANE-PROFILE VRF: default (Destination: 10.101.255.2, Next-hop: 10.101.255.1)
34+
35+
"""
36+
return f"AVT {self.avt_name} VRF: {self.vrf} (Destination: {self.destination}, Next-hop: {self.next_hop})"

anta/input_models/cvx.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) 2023-2024 Arista Networks, Inc.
2+
# Use of this source code is governed by the Apache License 2.0
3+
# that can be found in the LICENSE file.
4+
"""Module containing input models for CVX tests."""
5+
6+
from __future__ import annotations
7+
8+
from typing import Literal
9+
10+
from pydantic import BaseModel
11+
12+
from anta.custom_types import Hostname
13+
14+
15+
class CVXPeers(BaseModel):
16+
"""Model for a CVX Cluster Peer."""
17+
18+
peer_name: Hostname
19+
registration_state: Literal["Connecting", "Connected", "Registration error", "Registration complete", "Unexpected peer state"] = "Registration complete"

anta/input_models/interfaces.py

+30-5
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,42 @@
77

88
from typing import Literal
99

10-
from pydantic import BaseModel
10+
from pydantic import BaseModel, ConfigDict
1111

12-
from anta.custom_types import Interface
12+
from anta.custom_types import Interface, PortChannelInterface
1313

1414

1515
class InterfaceState(BaseModel):
1616
"""Model for an interface state."""
1717

18+
model_config = ConfigDict(extra="forbid")
1819
name: Interface
1920
"""Interface to validate."""
20-
status: Literal["up", "down", "adminDown"]
21-
"""Expected status of the interface."""
21+
status: Literal["up", "down", "adminDown"] | None = None
22+
"""Expected status of the interface. Required field in the `VerifyInterfacesStatus` test."""
2223
line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None
23-
"""Expected line protocol status of the interface."""
24+
"""Expected line protocol status of the interface. Optional field in the `VerifyInterfacesStatus` test."""
25+
portchannel: PortChannelInterface | None = None
26+
"""Port-Channel in which the interface is bundled. Required field in the `VerifyLACPInterfacesStatus` test."""
27+
lacp_rate_fast: bool = False
28+
"""Specifies the LACP timeout mode for the link aggregation group.
29+
30+
Options:
31+
- True: Also referred to as fast mode.
32+
- False: The default mode, also known as slow mode.
33+
34+
Can be enabled in the `VerifyLACPInterfacesStatus` tests.
35+
"""
36+
37+
def __str__(self) -> str:
38+
"""Return a human-readable string representation of the InterfaceState for reporting.
39+
40+
Examples
41+
--------
42+
- Interface: Ethernet1 Port-Channel: Port-Channel100
43+
- Interface: Ethernet1
44+
"""
45+
base_string = f"Interface: {self.name}"
46+
if self.portchannel is not None:
47+
base_string += f" Port-Channel: {self.portchannel}"
48+
return base_string

anta/input_models/security.py

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright (c) 2023-2024 Arista Networks, Inc.
2+
# Use of this source code is governed by the Apache License 2.0
3+
# that can be found in the LICENSE file.
4+
"""Module containing input models for security tests."""
5+
6+
from __future__ import annotations
7+
8+
from ipaddress import IPv4Address
9+
from typing import Any
10+
from warnings import warn
11+
12+
from pydantic import BaseModel, ConfigDict
13+
14+
15+
class IPSecPeer(BaseModel):
16+
"""IPSec (Internet Protocol Security) model represents the details of an IPv4 security peer."""
17+
18+
model_config = ConfigDict(extra="forbid")
19+
peer: IPv4Address
20+
"""The IPv4 address of the security peer."""
21+
vrf: str = "default"
22+
"""VRF context. Defaults to `default`."""
23+
connections: list[IPSecConn] | None = None
24+
"""A list of IPv4 security connections associated with the peer. Defaults to None."""
25+
26+
def __str__(self) -> str:
27+
"""Return a string representation of the IPSecPeer model. Used in failure messages.
28+
29+
Examples
30+
--------
31+
- Peer: 1.1.1.1 VRF: default
32+
"""
33+
return f"Peer: {self.peer} VRF: {self.vrf}"
34+
35+
36+
class IPSecConn(BaseModel):
37+
"""Details of an IPv4 security connection for a peer."""
38+
39+
model_config = ConfigDict(extra="forbid")
40+
source_address: IPv4Address
41+
"""The IPv4 address of the source in the security connection."""
42+
destination_address: IPv4Address
43+
"""The IPv4 address of the destination in the security connection."""
44+
45+
46+
class IPSecPeers(IPSecPeer): # pragma: no cover
47+
"""Alias for the IPSecPeers model to maintain backward compatibility.
48+
49+
When initialized, it will emit a deprecation warning and call the IPSecPeer model.
50+
51+
TODO: Remove this class in ANTA v2.0.0.
52+
"""
53+
54+
def __init__(self, **data: Any) -> None: # noqa: ANN401
55+
"""Initialize the IPSecPeer class, emitting a deprecation warning."""
56+
warn(
57+
message="IPSecPeers model is deprecated and will be removed in ANTA v2.0.0. Use the IPSecPeer model instead.",
58+
category=DeprecationWarning,
59+
stacklevel=2,
60+
)
61+
super().__init__(**data)

anta/input_models/stun.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright (c) 2023-2024 Arista Networks, Inc.
2+
# Use of this source code is governed by the Apache License 2.0
3+
# that can be found in the LICENSE file.
4+
"""Module containing input models for services tests."""
5+
6+
from __future__ import annotations
7+
8+
from ipaddress import IPv4Address
9+
10+
from pydantic import BaseModel, ConfigDict
11+
12+
from anta.custom_types import Port
13+
14+
15+
class StunClientTranslation(BaseModel):
16+
"""STUN (Session Traversal Utilities for NAT) model represents the configuration of an IPv4-based client translations."""
17+
18+
model_config = ConfigDict(extra="forbid")
19+
source_address: IPv4Address
20+
"""The IPv4 address of the STUN client"""
21+
source_port: Port = 4500
22+
"""The port number used by the STUN client for communication. Defaults to 4500."""
23+
public_address: IPv4Address | None = None
24+
"""The public-facing IPv4 address of the STUN client, discovered via the STUN server."""
25+
public_port: Port | None = None
26+
"""The public-facing port number of the STUN client, discovered via the STUN server."""
27+
28+
def __str__(self) -> str:
29+
"""Return a human-readable string representation of the StunClientTranslation for reporting.
30+
31+
Examples
32+
--------
33+
Client 10.0.0.1 Port: 4500
34+
"""
35+
return f"Client {self.source_address} Port: {self.source_port}"

0 commit comments

Comments
 (0)