Skip to content

Commit c473683

Browse files
authored
Merge branch 'main' into test_isis_graceful_restart
2 parents 44ed38a + e09488b commit c473683

Some content is hidden

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

53 files changed

+3448
-404
lines changed

.pre-commit-config.yaml

+4-4
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.4
49+
rev: v0.9.3
5050
hooks:
5151
- id: ruff
5252
name: Run Ruff linter
@@ -76,7 +76,7 @@ repos:
7676
- respx
7777

7878
- repo: https://github.com/codespell-project/codespell
79-
rev: v2.3.0
79+
rev: v2.4.0
8080
hooks:
8181
- id: codespell
8282
name: Checks for common misspellings in text files.
@@ -85,7 +85,7 @@ repos:
8585
types: [text]
8686

8787
- repo: https://github.com/pre-commit/mirrors-mypy
88-
rev: v1.14.0
88+
rev: v1.14.1
8989
hooks:
9090
- id: mypy
9191
name: Check typing with mypy
@@ -100,7 +100,7 @@ repos:
100100
files: ^(anta|tests)/
101101

102102
- repo: https://github.com/igorshubovych/markdownlint-cli
103-
rev: v0.43.0
103+
rev: v0.44.0
104104
hooks:
105105
- id: markdownlint
106106
name: Check Markdown files style.

anta/catalog.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[Mo
182182
except Exception as e:
183183
# A test module is potentially user-defined code.
184184
# We need to catch everything if we want to have meaningful logs
185-
module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}"
185+
module_str = f"{module_name.removeprefix('.')}{f' from package {package}' if package else ''}"
186186
message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues."
187187
anta_log_exception(e, message, logger)
188188
raise ValueError(message) from e
@@ -223,16 +223,14 @@ def check_tests(cls: type[AntaCatalogFile], data: Any) -> Any: # noqa: ANN401
223223
raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError
224224
if len(test_definition) != 1:
225225
msg = (
226-
f"Syntax error when parsing: {test_definition}\n"
227-
"It must be a dictionary with a single entry. Check the indentation in the test catalog."
226+
f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog."
228227
)
229228
raise ValueError(msg)
230229
for test_name, test_inputs in test_definition.copy().items():
231230
test: type[AntaTest] | None = getattr(module, test_name, None)
232231
if test is None:
233232
msg = (
234-
f"{test_name} is not defined in Python module {module.__name__}"
235-
f"{f' (from {module.__file__})' if module.__file__ is not None else ''}"
233+
f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}"
236234
)
237235
raise ValueError(msg)
238236
test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs))

anta/cli/utils.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from anta.catalog import AntaCatalog
1818
from anta.inventory import AntaInventory
1919
from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError
20+
from anta.logger import anta_log_exception
2021

2122
if TYPE_CHECKING:
2223
from click import Option
@@ -242,7 +243,8 @@ def wrapper(
242243
insecure=insecure,
243244
disable_cache=disable_cache,
244245
)
245-
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError):
246+
except (TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError) as e:
247+
anta_log_exception(e, f"Failed to parse the inventory: {inventory}", logger)
246248
ctx.exit(ExitCode.USAGE_ERROR)
247249
return f(*args, inventory=i, **kwargs)
248250

@@ -319,7 +321,8 @@ def wrapper(
319321
try:
320322
file_format = catalog_format.lower()
321323
c = AntaCatalog.parse(catalog, file_format=file_format) # type: ignore[arg-type]
322-
except (TypeError, ValueError, YAMLError, OSError):
324+
except (TypeError, ValueError, YAMLError, OSError) as e:
325+
anta_log_exception(e, f"Failed to parse the catalog: {catalog}", logger)
323326
ctx.exit(ExitCode.USAGE_ERROR)
324327
return f(*args, catalog=c, **kwargs)
325328

anta/constants.py

+8
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@
4545
r"No source interface .*",
4646
]
4747
"""List of known EOS errors that should set a test status to 'failure' with the error message."""
48+
49+
UNSUPPORTED_PLATFORM_ERRORS = [
50+
"not supported on this hardware platform",
51+
"Invalid input (at token 2: 'trident')",
52+
]
53+
"""Error messages indicating platform or hardware unsupported commands.
54+
Will set the test status to 'skipped'. Includes both general hardware
55+
platform errors and specific ASIC family limitations."""

anta/custom_types.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -154,16 +154,38 @@ def validate_regex(value: str) -> str:
154154
ErrDisableReasons = Literal[
155155
"acl",
156156
"arp-inspection",
157+
"bgp-session-tracking",
157158
"bpduguard",
159+
"dot1x",
160+
"dot1x-coa",
158161
"dot1x-session-replace",
162+
"evpn-sa-mh",
163+
"fabric-link-failure",
164+
"fabric-link-flap",
159165
"hitless-reload-down",
166+
"lacp-no-portid",
160167
"lacp-rate-limit",
168+
"license-enforce",
161169
"link-flap",
170+
"mlagasu",
171+
"mlagdualprimary",
172+
"mlagissu",
173+
"mlagmaintdown",
162174
"no-internal-vlan",
175+
"out-of-voqs",
163176
"portchannelguard",
177+
"portgroup-disabled",
164178
"portsec",
179+
"speed-misconfigured",
180+
"storm-control",
181+
"stp-no-portid",
182+
"stuck-queue",
165183
"tapagg",
166184
"uplink-failure-detection",
185+
"xcvr-misconfigured",
186+
"xcvr-overheat",
187+
"xcvr-power-unsupported",
188+
"xcvr-unsupported",
167189
]
168190
ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)]
169191
Percent = Annotated[float, Field(ge=0.0, le=100.0)]
@@ -208,7 +230,6 @@ def validate_regex(value: str) -> str:
208230
SnmpErrorCounter = Literal[
209231
"inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
210232
]
211-
212233
IPv4RouteType = Literal[
213234
"connected",
214235
"static",
@@ -238,3 +259,8 @@ def validate_regex(value: str) -> str:
238259
"Route Cache Route",
239260
"CBF Leaked Route",
240261
]
262+
SnmpVersion = Literal["v1", "v2c", "v3"]
263+
SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"]
264+
SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"]
265+
DynamicVlanSource = Literal["dmf", "dot1x", "dynvtep", "evpn", "mlag", "mlagsync", "mvpn", "swfwd", "vccbfd"]
266+
LogSeverityLevel = Literal["alerts", "critical", "debugging", "emergencies", "errors", "informational", "notifications", "warnings"]

anta/device.py

+77-18
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@
88
import asyncio
99
import logging
1010
from abc import ABC, abstractmethod
11-
from collections import defaultdict
11+
from collections import OrderedDict, defaultdict
12+
from time import monotonic
1213
from typing import TYPE_CHECKING, Any, Literal
1314

1415
import asyncssh
1516
import httpcore
16-
from aiocache import Cache
17-
from aiocache.plugins import HitMissRatioPlugin
1817
from asyncssh import SSHClientConnection, SSHClientConnectionOptions
1918
from httpx import ConnectError, HTTPError, TimeoutException
2019

@@ -34,6 +33,67 @@
3433
CLIENT_KEYS = asyncssh.public_key.load_default_keypairs()
3534

3635

36+
class AntaCache:
37+
"""Class to be used as cache.
38+
39+
Example
40+
-------
41+
42+
```python
43+
# Create cache
44+
cache = AntaCache("device1")
45+
with cache.locks[key]:
46+
command_output = cache.get(key)
47+
```
48+
"""
49+
50+
def __init__(self, device: str, max_size: int = 128, ttl: int = 60) -> None:
51+
"""Initialize the cache."""
52+
self.device = device
53+
self.cache: OrderedDict[str, Any] = OrderedDict()
54+
self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
55+
self.max_size = max_size
56+
self.ttl = ttl
57+
58+
# Stats
59+
self.stats: dict[str, int] = {}
60+
self._init_stats()
61+
62+
def _init_stats(self) -> None:
63+
"""Initialize the stats."""
64+
self.stats["hits"] = 0
65+
self.stats["total"] = 0
66+
67+
async def get(self, key: str) -> Any: # noqa: ANN401
68+
"""Return the cached entry for key."""
69+
self.stats["total"] += 1
70+
if key in self.cache:
71+
timestamp, value = self.cache[key]
72+
if monotonic() - timestamp < self.ttl:
73+
# checking the value is still valid
74+
self.cache.move_to_end(key)
75+
self.stats["hits"] += 1
76+
return value
77+
# Time expired
78+
del self.cache[key]
79+
del self.locks[key]
80+
return None
81+
82+
async def set(self, key: str, value: Any) -> bool: # noqa: ANN401
83+
"""Set the cached entry for key to value."""
84+
timestamp = monotonic()
85+
if len(self.cache) > self.max_size:
86+
self.cache.popitem(last=False)
87+
self.cache[key] = timestamp, value
88+
return True
89+
90+
def clear(self) -> None:
91+
"""Empty the cache."""
92+
logger.debug("Clearing cache for device %s", self.device)
93+
self.cache = OrderedDict()
94+
self._init_stats()
95+
96+
3797
class AntaDevice(ABC):
3898
"""Abstract class representing a device in ANTA.
3999
@@ -52,10 +112,11 @@ class AntaDevice(ABC):
52112
Hardware model of the device.
53113
tags : set[str]
54114
Tags for this device.
55-
cache : Cache | None
56-
In-memory cache from aiocache library for this device (None if cache is disabled).
115+
cache : AntaCache | None
116+
In-memory cache for this device (None if cache is disabled).
57117
cache_locks : dict
58118
Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled.
119+
Deprecated, will be removed in ANTA v2.0.0, use self.cache.locks instead.
59120
60121
"""
61122

@@ -79,7 +140,8 @@ def __init__(self, name: str, tags: set[str] | None = None, *, disable_cache: bo
79140
self.tags.add(self.name)
80141
self.is_online: bool = False
81142
self.established: bool = False
82-
self.cache: Cache | None = None
143+
self.cache: AntaCache | None = None
144+
# Keeping cache_locks for backward compatibility.
83145
self.cache_locks: defaultdict[str, asyncio.Lock] | None = None
84146

85147
# Initialize cache if not disabled
@@ -101,17 +163,16 @@ def __hash__(self) -> int:
101163

102164
def _init_cache(self) -> None:
103165
"""Initialize cache for the device, can be overridden by subclasses to manipulate how it works."""
104-
self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()])
105-
self.cache_locks = defaultdict(asyncio.Lock)
166+
self.cache = AntaCache(device=self.name, ttl=60)
167+
self.cache_locks = self.cache.locks
106168

107169
@property
108170
def cache_statistics(self) -> dict[str, Any] | None:
109171
"""Return the device cache statistics for logging purposes."""
110-
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
111-
# https://github.com/pylint-dev/pylint/issues/7258
112172
if self.cache is not None:
113-
stats = getattr(self.cache, "hit_miss_ratio", {"total": 0, "hits": 0, "hit_ratio": 0})
114-
return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{stats['hit_ratio'] * 100:.2f}%"}
173+
stats = self.cache.stats
174+
ratio = stats["hits"] / stats["total"] if stats["total"] > 0 else 0
175+
return {"total_commands_sent": stats["total"], "cache_hits": stats["hits"], "cache_hit_ratio": f"{ratio * 100:.2f}%"}
115176
return None
116177

117178
def __rich_repr__(self) -> Iterator[tuple[str, Any]]:
@@ -177,18 +238,16 @@ async def collect(self, command: AntaCommand, *, collection_id: str | None = Non
177238
collection_id
178239
An identifier used to build the eAPI request ID.
179240
"""
180-
# Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough
181-
# https://github.com/pylint-dev/pylint/issues/7258
182-
if self.cache is not None and self.cache_locks is not None and command.use_cache:
183-
async with self.cache_locks[command.uid]:
184-
cached_output = await self.cache.get(command.uid) # pylint: disable=no-member
241+
if self.cache is not None and command.use_cache:
242+
async with self.cache.locks[command.uid]:
243+
cached_output = await self.cache.get(command.uid)
185244

186245
if cached_output is not None:
187246
logger.debug("Cache hit for %s on %s", command.command, self.name)
188247
command.output = cached_output
189248
else:
190249
await self._collect(command=command, collection_id=collection_id)
191-
await self.cache.set(command.uid, command.output) # pylint: disable=no-member
250+
await self.cache.set(command.uid, command.output)
192251
else:
193252
await self._collect(command=command, collection_id=collection_id)
194253

anta/input_models/bfd.py

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class BFDPeer(BaseModel):
3131
"""Multiplier of BFD peer. Required field in the `VerifyBFDPeersIntervals` test."""
3232
protocols: list[BfdProtocol] | None = None
3333
"""List of protocols to be verified. Required field in the `VerifyBFDPeersRegProtocols` test."""
34+
detection_time: int | None = None
35+
"""Detection time of BFD peer in milliseconds. Defines how long to wait without receiving BFD packets before declaring the peer session as down.
36+
37+
Optional field in the `VerifyBFDPeersIntervals` test."""
3438

3539
def __str__(self) -> str:
3640
"""Return a human-readable string representation of the BFDPeer for reporting."""

anta/input_models/routing/bgp.py

+47-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations
77

88
from ipaddress import IPv4Address, IPv4Network, IPv6Address
9-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, Any, Literal
1010
from warnings import warn
1111

1212
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
@@ -142,12 +142,14 @@ class BgpPeer(BaseModel):
142142
"""IPv4 address of the BGP peer."""
143143
vrf: str = "default"
144144
"""Optional VRF for the BGP peer. Defaults to `default`."""
145+
peer_group: str | None = None
146+
"""Peer group of the BGP peer. Required field in the `VerifyBGPPeerGroup` test."""
145147
advertised_routes: list[IPv4Network] | None = None
146148
"""List of advertised routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test."""
147149
received_routes: list[IPv4Network] | None = None
148150
"""List of received routes in CIDR format. Required field in the `VerifyBGPExchangedRoutes` test."""
149151
capabilities: list[MultiProtocolCaps] | None = None
150-
"""List of BGP multiprotocol capabilities. Required field in the `VerifyBGPPeerMPCaps` test."""
152+
"""List of BGP multiprotocol capabilities. Required field in the `VerifyBGPPeerMPCaps`, `VerifyBGPNlriAcceptance` tests."""
151153
strict: bool = False
152154
"""If True, requires exact match of the provided BGP multiprotocol capabilities.
153155
@@ -209,3 +211,46 @@ class VxlanEndpoint(BaseModel):
209211
def __str__(self) -> str:
210212
"""Return a human-readable string representation of the VxlanEndpoint for reporting."""
211213
return f"Address: {self.address} VNI: {self.vni}"
214+
215+
216+
class BgpRoute(BaseModel):
217+
"""Model representing BGP routes.
218+
219+
Only IPv4 prefixes are supported for now.
220+
"""
221+
222+
model_config = ConfigDict(extra="forbid")
223+
prefix: IPv4Network
224+
"""The IPv4 network address."""
225+
vrf: str = "default"
226+
"""Optional VRF for the BGP peer. Defaults to `default`."""
227+
paths: list[BgpRoutePath]
228+
"""A list of paths for the BGP route."""
229+
230+
def __str__(self) -> str:
231+
"""Return a human-readable string representation of the BgpRoute for reporting.
232+
233+
Examples
234+
--------
235+
- Prefix: 192.168.66.100/24 VRF: default
236+
"""
237+
return f"Prefix: {self.prefix} VRF: {self.vrf}"
238+
239+
240+
class BgpRoutePath(BaseModel):
241+
"""Model representing a BGP route path."""
242+
243+
model_config = ConfigDict(extra="forbid")
244+
nexthop: IPv4Address
245+
"""The next-hop IPv4 address for the path."""
246+
origin: Literal["Igp", "Egp", "Incomplete"]
247+
"""The BGP origin attribute of the route."""
248+
249+
def __str__(self) -> str:
250+
"""Return a human-readable string representation of the RoutePath for reporting.
251+
252+
Examples
253+
--------
254+
- Next-hop: 192.168.66.101 Origin: Igp
255+
"""
256+
return f"Next-hop: {self.nexthop} Origin: {self.origin}"

0 commit comments

Comments
 (0)