From 4d5c6bb1677e4805cb858d94ae761f2a659275b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Tue, 14 May 2024 17:54:25 +0200 Subject: [PATCH 01/13] chore: import aioeapi --- aioeapi/__init__.py | 3 + aioeapi/aio_portcheck.py | 56 ++++++++ aioeapi/config_session.py | 259 +++++++++++++++++++++++++++++++++ aioeapi/device.py | 293 ++++++++++++++++++++++++++++++++++++++ aioeapi/errors.py | 36 +++++ anta/aioeapi.py | 106 -------------- 6 files changed, 647 insertions(+), 106 deletions(-) create mode 100644 aioeapi/__init__.py create mode 100644 aioeapi/aio_portcheck.py create mode 100644 aioeapi/config_session.py create mode 100644 aioeapi/device.py create mode 100644 aioeapi/errors.py delete mode 100644 anta/aioeapi.py diff --git a/aioeapi/__init__.py b/aioeapi/__init__.py new file mode 100644 index 000000000..723a20939 --- /dev/null +++ b/aioeapi/__init__.py @@ -0,0 +1,3 @@ +from .device import Device +from .errors import EapiCommandError +from .config_session import SessionConfig diff --git a/aioeapi/aio_portcheck.py b/aioeapi/aio_portcheck.py new file mode 100644 index 000000000..e82875d9f --- /dev/null +++ b/aioeapi/aio_portcheck.py @@ -0,0 +1,56 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from typing import Optional +import socket +import asyncio + +# ----------------------------------------------------------------------------- +# Public Imports +# ----------------------------------------------------------------------------- + + +from httpx import URL + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + +__all__ = ["port_check_url"] + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +async def port_check_url(url: URL, timeout: Optional[int] = 5) -> bool: + """ + This function attempts to open the port designated by the URL given the + timeout in seconds. If the port is avaialble then return True; False + otherwise. + + Parameters + ---------- + url: + The URL that provides the target system + + timeout: optional, default is 5 seonds + Time to await for the port to open in seconds + """ + port = url.port or socket.getservbyname(url.scheme) + + try: + wr: asyncio.StreamWriter + _, wr = await asyncio.wait_for( + asyncio.open_connection(host=url.host, port=port), timeout=timeout + ) + + # MUST close if opened! + wr.close() + return True + + except Exception: # noqa + return False diff --git a/aioeapi/config_session.py b/aioeapi/config_session.py new file mode 100644 index 000000000..f1beb5e42 --- /dev/null +++ b/aioeapi/config_session.py @@ -0,0 +1,259 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- +import re +from typing import Optional, TYPE_CHECKING, Union, List + +if TYPE_CHECKING: + from .device import Device + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + +__all__ = ["SessionConfig"] + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +class SessionConfig: + """ + The SessionConfig instance is used to send configuration to a device using + the EOS session mechanism. This is the preferred way of managing + configuraiton changes. + + Notes + ----- + This class definition is used by the parent Device class definition as + defined by `config_session`. A Caller can use the SessionConfig directly + as well, but it is not required. + """ + + CLI_CFG_FACTORY_RESET = "rollback clean-config" + + def __init__(self, device: "Device", name: str): + """ + Creates a new instance of the session config instance bound + to the given device instance, and using the session `name`. + + Parameters + ---------- + device: + The associated device instance + + name: + The name of the config session + """ + self._device = device + self._cli = device.cli + self._name = name + self._cli_config_session = f"configure session {self.name}" + + # ------------------------------------------------------------------------- + # properties for read-only attributes + # ------------------------------------------------------------------------- + + @property + def name(self) -> str: + """returns read-only session name attribute""" + return self._name + + @property + def device(self) -> "Device": + """returns read-only device instance attribute""" + return self._device + + # ------------------------------------------------------------------------- + # Public Methods + # ------------------------------------------------------------------------- + + async def status_all(self) -> dict: + """ + Get the status of the session config by running the command: + # show configuration sessions detail + + Returns + ------- + dict object of native EOS eAPI response; see `status` method for + details. + """ + return await self._cli("show configuration sessions detail") + + async def status(self) -> Union[dict, None]: + """ + Get the status of the session config by running the command: + # show configuration sessions detail + + And returning only the status dictionary for this session. If you want + all sessions, then use the `status_all` method. + + Returns + ------- + Dict instance of the session status. If the session does not exist, + then this method will return None. + + The native eAPI results from JSON output, see exmaple: + + Examples + -------- + all results: + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 + } + } + } + + if the session name was 'jeremy1', then this method would return + { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + } + """ + res = await self.status_all() + return res["sessions"].get(self.name) + + async def push( + self, content: Union[List[str], str], replace: Optional[bool] = False + ): + """ + Sends the configuration content to the device. If `replace` is true, + then the command "rollback clean-config" is issued before sendig the + configuration content. + + Parameters + ---------- + content: Union[List[str], str] + The text configuration CLI commands, as a list of strings, that + will be sent to the device. If the parameter is a string, and not + a list, then split the string across linebreaks. In either case + any empty lines will be discarded before they are send to the + device. + + replace: bool + When True, the content will replace the existing configuration + on the device. + """ + + # if given s string, we need to break it up into individual command + # lines. + + if isinstance(content, str): + content = content.splitlines() + + # prepare the initial set of command to enter the config session and + # rollback clean if the `replace` argument is True. + + commands = [self._cli_config_session] + if replace: + commands.append(self.CLI_CFG_FACTORY_RESET) + + # add the Caller's commands, filtering out any blank lines. any command + # lines (!) are still included. + + commands.extend(filter(None, content)) + + await self._cli(commands=commands) + + async def commit(self, timer: Optional[str] = None): + """ + Commits the session config using the commands + # configure session + # commit + + If the timer is specified, format is "hh:mm:ss", then a commit timer is + started. A second commit action must be made to confirm the config + session before the timer expires; otherwise the config-session is + automatically aborted. + """ + command = f"{self._cli_config_session} commit" + + if timer: + command += f" timer {timer}" + + await self._cli(command) + + async def abort(self): + """ + Aborts the configuration session using the command: + # configure session abort + """ + await self._cli(f"{self._cli_config_session} abort") + + async def diff(self) -> str: + """ + Returns the "diff" of the session config relative to the running config, using + the command: + # show session-config named diffs + + Returns + ------- + Returns a string in diff-patch format. + + References + ---------- + * https://www.gnu.org/software/diffutils/manual/diffutils.txt + """ + return await self._cli( + f"show session-config named {self.name} diffs", ofmt="text" + ) + + async def load_scp_file(self, filename: str, replace: Optional[bool] = False): + """ + This function is used to 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). + + Parameters + ---------- + filename: + The name of the configuration file. The caller is required to + specify the filesystem, for exmaple, the + filename="flash:thisfile.cfg" + + replace: + When True, the contents of the file will completely replace the + session config for a load-replace behavior. + + Raises + ------- + If there are any issues with loading the configuration file then a + RuntimeError is raised with the error messages content. + """ + commands = [self._cli_config_session] + if replace: + commands.append(self.CLI_CFG_FACTORY_RESET) + + commands.append(f"copy {filename} session-config") + res = await self._cli(commands=commands) + checks_re = re.compile(r"error|abort|invalid", flags=re.I) + messages = res[-1]["messages"] + + if any(map(checks_re.search, messages)): + raise RuntimeError("".join(messages)) + + async def write(self): + """ + Saves the running config to the startup config by issuing the command + "write" to the device. + """ + await self._cli("write") diff --git a/aioeapi/device.py b/aioeapi/device.py new file mode 100644 index 000000000..bdb562548 --- /dev/null +++ b/aioeapi/device.py @@ -0,0 +1,293 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import Optional, Union, AnyStr +from socket import getservbyname + +# ----------------------------------------------------------------------------- +# Public Imports +# ----------------------------------------------------------------------------- + +import httpx + +# ----------------------------------------------------------------------------- +# Private Imports +# ----------------------------------------------------------------------------- + +from .aio_portcheck import port_check_url +from .errors import EapiCommandError +from .config_session import SessionConfig + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + + +__all__ = ["Device"] + + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +class Device(httpx.AsyncClient): + """ + The Device class represents 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. + """ + + auth = None + EAPI_OFMT_OPTIONS = ("json", "text") + EAPI_DEFAULT_OFMT = "json" + + def __init__( + self, + host: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + proto: Optional[str] = "https", + port=None, + **kwargs, + ): + """ + Initializes the Device class. As a subclass to httpx.AsyncClient, the + Caller can provide any of those initializers. Specific paramertes for + Device class are all optional and described below. + + Parameters + ---------- + host: Optional[str] + The EOS target device, either hostname (DNS) or ipaddress. + + username: Optional[str] + The login user-name; requires the password parameter. + + password: Optional[str] + The login password; requires the username parameter. + + proto: Optional[str] + The protocol, http or https, to communicate eAPI with the device. + + port: Optional[Union[str,int]] + If not provided, the proto value is used to look up the associated + port (http=80, https=443). If provided, overrides the port used to + communite with the device. + + Other Parameters + ---------------- + base_url: str + If provided, the complete URL to the device eAPI endpoint. + + auth: + If provided, used as the httpx authorization initializer value. If + not provided, then username+password is assumed by the Caller and + used to create a BasicAuth instance. + """ + + self.port = port or getservbyname(proto) + self.host = host + kwargs.setdefault("base_url", httpx.URL(f"{proto}://{self.host}:{self.port}")) + kwargs.setdefault("verify", False) + + if username and password: + self.auth = httpx.BasicAuth(username, password) + + kwargs.setdefault("auth", self.auth) + + super(Device, self).__init__(**kwargs) + self.headers["Content-Type"] = "application/json-rpc" + + async def check_connection(self) -> bool: + """ + This function checks the target device to ensure that the eAPI port is + open and accepting connections. It is recommended that a Caller checks + the connection before involing cli commands, but this step is not + required. + + Returns + ------- + True when the device eAPI is accessible, False otherwise. + """ + return await port_check_url(self.base_url) + + async def cli( + self, + command: Optional[AnyStr] = None, + commands: Optional[list[AnyStr]] = None, + ofmt: Optional[str] = None, + suppress_error: Optional[bool] = False, + version: Optional[Union[int, str]] = "latest", + **kwargs, + ): + """ + Execute one or more CLI commands. + + Parameters + ---------- + command: str + A single command to execute; results in a single output response + + commands: List[str] + A list of commands to execute; results in a list of output responses + + ofmt: str + Either 'json' or 'text'; indicates the output fromat for the CLI commands. + + suppress_error: Optional[bool] = False + When not False, then if the execution of the command would-have + raised an EapiCommandError, rather than raising this exception this + routine will return the value None. + + For example, if the following command had raised + EapiCommandError, now response would be set to None instead. + + response = dev.cli(..., suppress_error=True) + + version: Optional[int | string] + By default the eAPI will use "version 1" for all API object models. + This driver will, by default, always set version to "latest" so + that the behavior matches the CLI of the device. The caller can + override the "latest" behavior by explicity setting the version. + + + Other Parameters + ---------------- + autoComplete: Optional[bool] = False + Enabled/disables the command auto-compelete feature of the EAPI. Per the + documentation: + Allows users to use shorthand commands in eAPI calls. With this + parameter included a user can send 'sh ver' via eAPI to get the + output of 'show version'. + + expandAliases: Optional[bool] = False + Enables/disables the command use of User defined alias. Per the + documentation: + Allowed users to provide the expandAliases parameter to eAPI + calls. This allows users to use aliased commands via the API. + For example if an alias is configured as 'sv' for 'show version' + then an API call with sv and the expandAliases parameter will + return the output of show version. + + Returns + ------- + One or List of output respones, per the description above. + """ + if not any((command, commands)): + raise RuntimeError("Required 'command' or 'commands'") + + jsonrpc = self.jsoncrpc_command( + commands=[command] if command else commands, + ofmt=ofmt, + version=version, + **kwargs, + ) + + try: + res = await self.jsonrpc_exec(jsonrpc) + return res[0] if command else res + except EapiCommandError as eapi_error: + if suppress_error: + return None + raise eapi_error + + def jsoncrpc_command(self, commands, ofmt, version, **kwargs) -> dict: + """Used to create the JSON-RPC command dictionary object""" + + cmd = { + "jsonrpc": "2.0", + "method": "runCmds", + "params": { + "version": version, + "cmds": commands, + "format": ofmt or self.EAPI_DEFAULT_OFMT, + }, + "id": str(kwargs.get("req_id") or id(self)), + } + if "autoComplete" in kwargs: + cmd["params"]["autoComplete"] = kwargs["autoComplete"] + + if "expandAliases" in kwargs: + cmd["params"]["expandAliases"] = kwargs["expandAliases"] + + return cmd + + async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: + """ + Execute the JSON-RPC dictionary object. + + Parameters + ---------- + jsonrpc: dict + The JSON-RPC as created by the `meth`:jsonrpc_command(). + + Raises + ------ + EapiCommandError + In the event that a command resulted in an error response. + + Returns + ------- + The list of command results; either dict or text depending on the + JSON-RPC format pameter. + """ + res = await self.post("/command-api", json=jsonrpc) + res.raise_for_status() + body = res.json() + + commands = jsonrpc["params"]["cmds"] + ofmt = jsonrpc["params"]["format"] + + get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r) + + # if there are no errors then return the list of command results. + if (err_data := body.get("error")) is None: + return [get_output(cmd_res) for cmd_res in body["result"]] + + # --------------------------------------------------------------------- + # if we are here, then there were some command errors. Raise a + # EapiCommandError exception with args (commands that failed, passed, + # not-executed). + # --------------------------------------------------------------------- + + # -------------------------- eAPI specification ---------------------- + # On an error, no result object is present, only an error object, which + # is guaranteed to have the following attributes: code, messages, and + # data. Similar to the result object in the successful response, the + # data object is a list of objects corresponding to the results of all + # commands up to, and including, the failed command. If there was a an + # error before any commands were executed (e.g. bad credentials), data + # will be empty. The last object in the data array will always + # correspond to the failed command. The command failure details are + # always stored in the errors array. + + cmd_data = err_data["data"] + len_data = len(cmd_data) + err_at = len_data - 1 + err_msg = err_data["message"] + + raise EapiCommandError( + passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])], + failed=commands[err_at]["cmd"], + errors=cmd_data[err_at]["errors"], + errmsg=err_msg, + not_exec=commands[err_at + 1 :], + ) + + def config_session(self, name: str) -> SessionConfig: + """ + Factory method that returns a SessionConfig instance bound to this + device with the given session name. + + Parameters + ---------- + name: + The config-session name + """ + return SessionConfig(self, name) diff --git a/aioeapi/errors.py b/aioeapi/errors.py new file mode 100644 index 000000000..e541cb986 --- /dev/null +++ b/aioeapi/errors.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import httpx + +from typing import Any + + +class EapiCommandError(RuntimeError): + """ + Exception class for EAPI command errors + + Attributes + ---------- + failed: str - the failed command + errmsg: str - a description of the failure reason + errors: list[str] - the command failure details + passed: list[dict] - a list of command results of the commands that passed + not_exec: list[str] - a list of commands that were not executed + """ + + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]): + """Initializer for the EapiCommandError exception""" + self.failed = failed + self.errmsg = errmsg + self.errors = errors + self.passed = passed + self.not_exec = not_exec + super().__init__() + + def __str__(self) -> str: + """returns the error message associated with the exception""" + return self.errmsg + + +# alias for exception during sending-receiving +EapiTransportError = httpx.HTTPStatusError diff --git a/anta/aioeapi.py b/anta/aioeapi.py deleted file mode 100644 index f99ea1851..000000000 --- a/anta/aioeapi.py +++ /dev/null @@ -1,106 +0,0 @@ -# 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. -"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13.""" -from __future__ import annotations - -from typing import Any, AnyStr - -import aioeapi - -Device = aioeapi.Device - - -class EapiCommandError(RuntimeError): - """Exception class for EAPI command errors. - - Attributes - ---------- - failed: str - the failed command - errmsg: str - a description of the failure reason - errors: list[str] - the command failure details - passed: list[dict] - a list of command results of the commands that passed - not_exec: list[str] - a list of commands that were not executed - """ - - # pylint: disable=too-many-arguments - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: - """Initializer for the EapiCommandError exception.""" - self.failed = failed - self.errmsg = errmsg - self.errors = errors - self.passed = passed - self.not_exec = not_exec - super().__init__() - - def __str__(self) -> str: - """Returns the error message associated with the exception.""" - return self.errmsg - - -aioeapi.EapiCommandError = EapiCommandError - - -async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore - """Execute the JSON-RPC dictionary object. - - Parameters - ---------- - jsonrpc: dict - The JSON-RPC as created by the `meth`:jsonrpc_command(). - - Raises - ------ - EapiCommandError - In the event that a command resulted in an error response. - - Returns - ------- - The list of command results; either dict or text depending on the - JSON-RPC format pameter. - """ - res = await self.post("/command-api", json=jsonrpc) - res.raise_for_status() - body = res.json() - - commands = jsonrpc["params"]["cmds"] - ofmt = jsonrpc["params"]["format"] - - get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r) - - # if there are no errors then return the list of command results. - if (err_data := body.get("error")) is None: - return [get_output(cmd_res) for cmd_res in body["result"]] - - # --------------------------------------------------------------------- - # if we are here, then there were some command errors. Raise a - # EapiCommandError exception with args (commands that failed, passed, - # not-executed). - # --------------------------------------------------------------------- - - # -------------------------- eAPI specification ---------------------- - # On an error, no result object is present, only an error object, which - # is guaranteed to have the following attributes: code, messages, and - # data. Similar to the result object in the successful response, the - # data object is a list of objects corresponding to the results of all - # commands up to, and including, the failed command. If there was a an - # error before any commands were executed (e.g. bad credentials), data - # will be empty. The last object in the data array will always - # correspond to the failed command. The command failure details are - # always stored in the errors array. - - cmd_data = err_data["data"] - len_data = len(cmd_data) - err_at = len_data - 1 - err_msg = err_data["message"] - - raise EapiCommandError( - passed=[get_output(cmd_data[cmd_i]) for cmd_i, cmd in enumerate(commands[:err_at])], - failed=commands[err_at]["cmd"], - errors=cmd_data[err_at]["errors"], - errmsg=err_msg, - not_exec=commands[err_at + 1 :], - ) - - -aioeapi.Device.jsonrpc_exec = jsonrpc_exec From 73337aed10e2ab797a62441e34b2f143f2b8f75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Tue, 14 May 2024 17:54:42 +0200 Subject: [PATCH 02/13] chore: update pyproject.toml --- pyproject.toml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a41214aa8..6dd77d28b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ description = "Arista Network Test Automation (ANTA) Framework" license = { file = "LICENSE" } dependencies = [ "aiocache>=0.12.2", - "aio-eapi>=0.6.3", "asyncssh>=2.13.2", "cvprac>=1.3.1", "eval-type-backport>=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed) @@ -28,6 +27,7 @@ dependencies = [ "PyYAML>=6.0", "requests>=2.31.0", "rich>=13.5.2,<14", + "httpx>=0.27.0" ] keywords = ["test", "anta", "Arista", "network", "automation", "networking", "devops", "netdevops"] classifiers = [ @@ -103,7 +103,7 @@ anta = "anta.cli:cli" # Tools ################################ [tool.setuptools.packages.find] -include = ["anta*"] +include = ["anta*", "aioeapi*"] namespaces = false ################################ @@ -177,10 +177,6 @@ filterwarnings = [ branch = true source = ["anta"] parallel = true -omit= [ - # omit aioeapi patch - "anta/aioeapi.py", -] [tool.coverage.report] # Regexes for lines to exclude from consideration @@ -312,7 +308,6 @@ exclude = [ "site-packages", "venv", ".github", - "aioeapi.py", # Remove this when https://github.com/jeremyschulman/aio-eapi/pull/13 is merged ] line-length = 165 From a81e62f6c29dc9471c02b74f5bc903b3bab52364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 15 May 2024 13:46:43 +0200 Subject: [PATCH 03/13] refactor: lint aioeapi module --- aioeapi/__init__.py | 8 +- aioeapi/aio_portcheck.py | 35 +++--- aioeapi/config_session.py | 230 +++++++++++++++++++++----------------- aioeapi/device.py | 197 ++++++++++++++++---------------- aioeapi/errors.py | 24 ++-- 5 files changed, 259 insertions(+), 235 deletions(-) diff --git a/aioeapi/__init__.py b/aioeapi/__init__.py index 723a20939..decd3e16b 100644 --- a/aioeapi/__init__.py +++ b/aioeapi/__init__.py @@ -1,3 +1,9 @@ +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi + +"""Arista EOS eAPI asyncio client.""" + +from .config_session import SessionConfig from .device import Device from .errors import EapiCommandError -from .config_session import SessionConfig + +__all__ = ["Device", "SessionConfig", "EapiCommandError"] diff --git a/aioeapi/aio_portcheck.py b/aioeapi/aio_portcheck.py index e82875d9f..0d43e46a6 100644 --- a/aioeapi/aio_portcheck.py +++ b/aioeapi/aio_portcheck.py @@ -1,17 +1,20 @@ +"""Utility function to check if a port is open.""" # ----------------------------------------------------------------------------- # System Imports # ----------------------------------------------------------------------------- -from typing import Optional -import socket +from __future__ import annotations + import asyncio +import socket +from typing import TYPE_CHECKING # ----------------------------------------------------------------------------- # Public Imports # ----------------------------------------------------------------------------- - -from httpx import URL +if TYPE_CHECKING: + from httpx import URL # ----------------------------------------------------------------------------- # Exports @@ -26,31 +29,27 @@ # ----------------------------------------------------------------------------- -async def port_check_url(url: URL, timeout: Optional[int] = 5) -> bool: +async def port_check_url(url: URL, timeout: int = 5) -> bool: """ - This function attempts to open the port designated by the URL given the - timeout in seconds. If the port is avaialble then return True; False - otherwise. + Open the port designated by the URL given the timeout in seconds. + + If the port is avaialble then return True; False otherwise. Parameters ---------- - url: - The URL that provides the target system - - timeout: optional, default is 5 seonds - Time to await for the port to open in seconds + url: The URL that provides the target system + timeout: Time to await for the port to open in seconds """ port = url.port or socket.getservbyname(url.scheme) try: wr: asyncio.StreamWriter - _, wr = await asyncio.wait_for( - asyncio.open_connection(host=url.host, port=port), timeout=timeout - ) + _, wr = await asyncio.wait_for(asyncio.open_connection(host=url.host, port=port), timeout=timeout) # MUST close if opened! wr.close() - return True - except Exception: # noqa + except TimeoutError: return False + else: + return True diff --git a/aioeapi/config_session.py b/aioeapi/config_session.py index f1beb5e42..2327d762e 100644 --- a/aioeapi/config_session.py +++ b/aioeapi/config_session.py @@ -1,8 +1,12 @@ +"""aioeapi.SessionConfig definition.""" + # ----------------------------------------------------------------------------- # System Imports # ----------------------------------------------------------------------------- +from __future__ import annotations + import re -from typing import Optional, TYPE_CHECKING, Union, List +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .device import Device @@ -22,31 +26,30 @@ class SessionConfig: """ - The SessionConfig instance is used to send configuration to a device using - the EOS session mechanism. This is the preferred way of managing - configuraiton changes. + Send configuration to a device using the EOS session mechanism. + + This is the preferred way of managing configuraiton changes. Notes ----- - This class definition is used by the parent Device class definition as - defined by `config_session`. A Caller can use the SessionConfig directly - as well, but it is not required. + This class definition is used by the parent Device class definition as + defined by `config_session`. A Caller can use the SessionConfig directly + as well, but it is not required. """ CLI_CFG_FACTORY_RESET = "rollback clean-config" - def __init__(self, device: "Device", name: str): + def __init__(self, device: Device, name: str) -> None: """ - Creates a new instance of the session config instance bound + Create a new instance of SessionConfig. + + The session config instance bound to the given device instance, and using the session `name`. Parameters ---------- - device: - The associated device instance - - name: - The name of the config session + device: The associated device instance + name: The name of the config session """ self._device = device self._cli = device.cli @@ -59,101 +62,124 @@ def __init__(self, device: "Device", name: str): @property def name(self) -> str: - """returns read-only session name attribute""" + """Return read-only session name attribute.""" return self._name @property - def device(self) -> "Device": - """returns read-only device instance attribute""" + def device(self) -> Device: + """Return read-only device instance attribute.""" return self._device # ------------------------------------------------------------------------- # Public Methods # ------------------------------------------------------------------------- - async def status_all(self) -> dict: + async def status_all(self) -> dict[str, Any]: """ - Get the status of the session config by running the command: + Get the status of all the session config on the device. + + Run the following command on the device: # show configuration sessions detail Returns ------- - dict object of native EOS eAPI response; see `status` method for - details. + Dict object of native EOS eAPI response; see `status` method for + details. + + Examples + -------- + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 + } + } + } """ - return await self._cli("show configuration sessions detail") + return await self._cli("show configuration sessions detail") # type: ignore[return-value] # json outformat returns dict[str, Any] - async def status(self) -> Union[dict, None]: + async def status(self) -> dict[str, Any] | None: """ - Get the status of the session config by running the command: + Get the status of a session config on the device. + + Run the following command on the device: # show configuration sessions detail - And returning only the status dictionary for this session. If you want + And return only the status dictionary for this session. If you want all sessions, then use the `status_all` method. Returns ------- - Dict instance of the session status. If the session does not exist, - then this method will return None. + Dict instance of the session status. If the session does not exist, + then this method will return None. - The native eAPI results from JSON output, see exmaple: + The native eAPI results from JSON output, see exmaple: Examples -------- - all results: - { - "maxSavedSessions": 1, - "maxOpenSessions": 5, - "sessions": { - "jeremy1": { - "instances": {}, - "state": "pending", - "commitUser": "", - "description": "" - }, - "ansible_167510439362": { - "instances": {}, - "state": "completed", - "commitUser": "joe.bob", - "description": "", - "completedTime": 1675104396.4500246 + all results: + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 + } } } - } - - if the session name was 'jeremy1', then this method would return - { - "instances": {}, - "state": "pending", - "commitUser": "", - "description": "" - } + + if the session name was 'jeremy1', then this method would return + { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + } """ res = await self.status_all() return res["sessions"].get(self.name) - async def push( - self, content: Union[List[str], str], replace: Optional[bool] = False - ): + async def push(self, content: list[str] | str, *, replace: bool = False) -> None: """ - Sends the configuration content to the device. If `replace` is true, - then the command "rollback clean-config" is issued before sendig the - configuration content. + Send the configuration content to the device. + + If `replace` is true, then the command "rollback clean-config" is issued + before sending the configuration content. Parameters ---------- - content: Union[List[str], str] - The text configuration CLI commands, as a list of strings, that - will be sent to the device. If the parameter is a string, and not - a list, then split the string across linebreaks. In either case - any empty lines will be discarded before they are send to the - device. - - replace: bool - When True, the content will replace the existing configuration - on the device. + content: + The text configuration CLI commands, as a list of strings, that + will be sent to the device. If the parameter is a string, and not + a list, then split the string across linebreaks. In either case + any empty lines will be discarded before they are send to the + device. + replace: + When True, the content will replace the existing configuration + on the device. """ - # if given s string, we need to break it up into individual command # lines. @@ -174,9 +200,11 @@ async def push( await self._cli(commands=commands) - async def commit(self, timer: Optional[str] = None): + async def commit(self, timer: str | None = None) -> None: """ - Commits the session config using the commands + Commit the session config. + + Run the following command on the device: # configure session # commit @@ -192,68 +220,66 @@ async def commit(self, timer: Optional[str] = None): await self._cli(command) - async def abort(self): + async def abort(self) -> None: """ - Aborts the configuration session using the command: + Abort the configuration session. + + Run the following command on the device: # configure session abort """ await self._cli(f"{self._cli_config_session} abort") async def diff(self) -> str: """ - Returns the "diff" of the session config relative to the running config, using - the command: + Return the "diff" of the session config relative to the running config. + + Run the following command on the device: # show session-config named diffs Returns ------- - Returns a string in diff-patch format. + Return a string in diff-patch format. References ---------- - * https://www.gnu.org/software/diffutils/manual/diffutils.txt + * https://www.gnu.org/software/diffutils/manual/diffutils.txt """ - return await self._cli( - f"show session-config named {self.name} diffs", ofmt="text" - ) + 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_scp_file(self, filename: str, replace: Optional[bool] = False): + async def load_file(self, filename: str, *, replace: bool = False) -> None: """ - This function is used to 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). + 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). Parameters ---------- - filename: - The name of the configuration file. The caller is required to - specify the filesystem, for exmaple, the - filename="flash:thisfile.cfg" + filename: + The name of the configuration file. The caller is required to + specify the filesystem, for exmaple, the + filename="flash:thisfile.cfg" - replace: - When True, the contents of the file will completely replace the - session config for a load-replace behavior. + replace: + When True, the contents of the file will completely replace the + session config for a load-replace behavior. Raises - ------- - If there are any issues with loading the configuration file then a - RuntimeError is raised with the error messages content. + ------ + If there are any issues with loading the configuration file then a + RuntimeError is raised with the error messages content. """ commands = [self._cli_config_session] if replace: commands.append(self.CLI_CFG_FACTORY_RESET) commands.append(f"copy {filename} session-config") - res = await self._cli(commands=commands) + res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]] checks_re = re.compile(r"error|abort|invalid", flags=re.I) messages = res[-1]["messages"] if any(map(checks_re.search, messages)): raise RuntimeError("".join(messages)) - async def write(self): - """ - Saves the running config to the startup config by issuing the command - "write" to the device. - """ + async def write(self) -> None: + """Save the running config to the startup config by issuing the command "write" to the device.""" await self._cli("write") diff --git a/aioeapi/device.py b/aioeapi/device.py index bdb562548..0cea1799d 100644 --- a/aioeapi/device.py +++ b/aioeapi/device.py @@ -1,25 +1,24 @@ +"""aioeapi.Device definition.""" # ----------------------------------------------------------------------------- # System Imports # ----------------------------------------------------------------------------- from __future__ import annotations -from typing import Optional, Union, AnyStr from socket import getservbyname +from typing import Any # ----------------------------------------------------------------------------- # Public Imports # ----------------------------------------------------------------------------- - import httpx # ----------------------------------------------------------------------------- # Private Imports # ----------------------------------------------------------------------------- - from .aio_portcheck import port_check_url -from .errors import EapiCommandError from .config_session import SessionConfig +from .errors import EapiCommandError # ----------------------------------------------------------------------------- # Exports @@ -38,8 +37,9 @@ class Device(httpx.AsyncClient): """ - The Device class represents the async JSON-RPC client that communicates with - an Arista EOS device. This class inherits directly from the + 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. """ @@ -47,50 +47,41 @@ class Device(httpx.AsyncClient): EAPI_OFMT_OPTIONS = ("json", "text") EAPI_DEFAULT_OFMT = "json" - def __init__( + def __init__( # noqa: PLR0913 self, - host: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, - proto: Optional[str] = "https", - port=None, - **kwargs, - ): + host: str | None = None, + username: str | None = None, + password: str | None = None, + proto: str = "https", + port: str | int | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: """ - Initializes the Device class. As a subclass to httpx.AsyncClient, the - Caller can provide any of those initializers. Specific paramertes for - Device class are all optional and described below. + 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. Parameters ---------- - host: Optional[str] - The EOS target device, either hostname (DNS) or ipaddress. - - username: Optional[str] - The login user-name; requires the password parameter. - - password: Optional[str] - The login password; requires the username parameter. - - proto: Optional[str] - The protocol, http or https, to communicate eAPI with the device. - - port: Optional[Union[str,int]] - If not provided, the proto value is used to look up the associated - port (http=80, https=443). If provided, overrides the port used to - communite with the device. + host: The EOS target device, either hostname (DNS) or ipaddress. + username: The login user-name; requires the password parameter. + password: The login password; requires the username parameter. + proto: The protocol, http or https, to communicate eAPI with the device. + port: If not provided, the proto value is used to look up the associated + port (http=80, https=443). If provided, overrides the port used to + communite with the device. Other Parameters ---------------- - base_url: str - If provided, the complete URL to the device eAPI endpoint. + base_url: str + If provided, the complete URL to the device eAPI endpoint. - auth: - If provided, used as the httpx authorization initializer value. If - not provided, then username+password is assumed by the Caller and - used to create a BasicAuth instance. + auth: + If provided, used as the httpx authorization initializer value. If + not provided, then username+password is assumed by the Caller and + used to create a BasicAuth instance. """ - self.port = port or getservbyname(proto) self.host = host kwargs.setdefault("base_url", httpx.URL(f"{proto}://{self.host}:{self.port}")) @@ -101,46 +92,51 @@ def __init__( kwargs.setdefault("auth", self.auth) - super(Device, self).__init__(**kwargs) + super().__init__(**kwargs) self.headers["Content-Type"] = "application/json-rpc" async def check_connection(self) -> bool: """ - This function checks the target device to ensure that the eAPI port is - open and accepting connections. It is recommended that a Caller checks - the connection before involing cli commands, but this step is not - required. + 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 involing cli commands, + but this step is not required. Returns ------- - True when the device eAPI is accessible, False otherwise. + True when the device eAPI is accessible, False otherwise. """ return await port_check_url(self.base_url) - async def cli( + async def cli( # noqa: PLR0913 self, - command: Optional[AnyStr] = None, - commands: Optional[list[AnyStr]] = None, - ofmt: Optional[str] = None, - suppress_error: Optional[bool] = False, - version: Optional[Union[int, str]] = "latest", - **kwargs, - ): + command: str | None = None, + commands: list[str] | None = None, + ofmt: str | None = None, + version: int | str | None = "latest", + *, + suppress_error: bool = False, + auto_complete: bool = False, + 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. Parameters ---------- - command: str + command: A single command to execute; results in a single output response - - commands: List[str] + commands: A list of commands to execute; results in a list of output responses - - ofmt: str + ofmt: Either 'json' or 'text'; indicates the output fromat for the CLI commands. - - suppress_error: Optional[bool] = False + version: + By default the eAPI will use "version 1" for all API object models. + This driver will, by default, always set version to "latest" so + that the behavior matches the CLI of the device. The caller can + override the "latest" behavior by explicity setting the version. + suppress_error: When not False, then if the execution of the command would-have raised an EapiCommandError, rather than raising this exception this routine will return the value None. @@ -149,24 +145,13 @@ async def cli( EapiCommandError, now response would be set to None instead. response = dev.cli(..., suppress_error=True) - - version: Optional[int | string] - By default the eAPI will use "version 1" for all API object models. - This driver will, by default, always set version to "latest" so - that the behavior matches the CLI of the device. The caller can - override the "latest" behavior by explicity setting the version. - - - Other Parameters - ---------------- - autoComplete: Optional[bool] = False + auto_complete: Enabled/disables the command auto-compelete feature of the EAPI. Per the documentation: Allows users to use shorthand commands in eAPI calls. With this parameter included a user can send 'sh ver' via eAPI to get the output of 'show version'. - - expandAliases: Optional[bool] = False + expand_aliases: Enables/disables the command use of User defined alias. Per the documentation: Allowed users to provide the expandAliases parameter to eAPI @@ -174,33 +159,41 @@ async def cli( For example if an alias is configured as 'sv' for 'show version' then an API call with sv and the expandAliases parameter will return the output of show version. + req_id: + A unique identifier that will be echoed back by the switch. May be a string or number. Returns ------- - One or List of output respones, per the description above. + One or List of output respones, per the description above. """ if not any((command, commands)): - raise RuntimeError("Required 'command' or 'commands'") + msg = "Required 'command' or 'commands'" + raise RuntimeError(msg) - jsonrpc = self.jsoncrpc_command( - commands=[command] if command else commands, - ofmt=ofmt, - version=version, - **kwargs, + jsonrpc = self._jsonrpc_command( + commands=[command] if command else commands, ofmt=ofmt, version=version, auto_complete=auto_complete, expand_aliases=expand_aliases, req_id=req_id ) try: res = await self.jsonrpc_exec(jsonrpc) return res[0] if command else res - except EapiCommandError as eapi_error: + except EapiCommandError: if suppress_error: return None - raise eapi_error - - def jsoncrpc_command(self, commands, ofmt, version, **kwargs) -> dict: - """Used to create the JSON-RPC command dictionary object""" + raise - cmd = { + def _jsonrpc_command( # noqa: PLR0913 + self, + commands: list[str] | None = None, + ofmt: str | None = None, + version: int | str | None = "latest", + *, + auto_complete: bool = False, + expand_aliases: bool = False, + req_id: int | str | None = None, + ) -> dict[str, Any]: + """Create the JSON-RPC command dictionary object.""" + cmd: dict[str, Any] = { "jsonrpc": "2.0", "method": "runCmds", "params": { @@ -208,34 +201,34 @@ def jsoncrpc_command(self, commands, ofmt, version, **kwargs) -> dict: "cmds": commands, "format": ofmt or self.EAPI_DEFAULT_OFMT, }, - "id": str(kwargs.get("req_id") or id(self)), + "id": req_id or id(self), } - if "autoComplete" in kwargs: - cmd["params"]["autoComplete"] = kwargs["autoComplete"] + if auto_complete is not None: + cmd["params"].update({"autoComplete": auto_complete}) - if "expandAliases" in kwargs: - cmd["params"]["expandAliases"] = kwargs["expandAliases"] + if expand_aliases is not None: + cmd["params"].update({"expandAliases": expand_aliases}) return cmd - async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: + async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | str]: """ Execute the JSON-RPC dictionary object. Parameters ---------- - jsonrpc: dict - The JSON-RPC as created by the `meth`:jsonrpc_command(). + jsonrpc: + The JSON-RPC as created by the `meth`:_jsonrpc_command(). Raises ------ - EapiCommandError - In the event that a command resulted in an error response. + EapiCommandError + In the event that a command resulted in an error response. Returns ------- - The list of command results; either dict or text depending on the - JSON-RPC format pameter. + The list of command results; either dict or text depending on the + JSON-RPC format parameter. """ res = await self.post("/command-api", json=jsonrpc) res.raise_for_status() @@ -282,12 +275,10 @@ async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: def config_session(self, name: str) -> SessionConfig: """ - Factory method that returns a SessionConfig instance bound to this - device with the given session name. + return a SessionConfig instance bound to this device with the given session name. Parameters ---------- - name: - The config-session name + name: The config-session name """ return SessionConfig(self, name) diff --git a/aioeapi/errors.py b/aioeapi/errors.py index e541cb986..bc3bfd6fd 100644 --- a/aioeapi/errors.py +++ b/aioeapi/errors.py @@ -1,25 +1,27 @@ -from __future__ import annotations +"""aioeapi module exceptions.""" -import httpx +from __future__ import annotations from typing import Any +import httpx + class EapiCommandError(RuntimeError): """ - Exception class for EAPI command errors + Exception class for EAPI command errors. Attributes ---------- - failed: str - the failed command - errmsg: str - a description of the failure reason - errors: list[str] - the command failure details - passed: list[dict] - a list of command results of the commands that passed - not_exec: list[str] - a list of commands that were not executed + failed: the failed command + errmsg: a description of the failure reason + errors: the command failure details + passed: a list of command results of the commands that passed + not_exec: a list of commands that were not executed """ - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]): - """Initializer for the EapiCommandError exception""" + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # noqa: PLR0913 + """Initialize for the EapiCommandError exception.""" self.failed = failed self.errmsg = errmsg self.errors = errors @@ -28,7 +30,7 @@ def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str super().__init__() def __str__(self) -> str: - """returns the error message associated with the exception""" + """Return the error message associated with the exception.""" return self.errmsg From 4d0956d36c22c4a0b3bbb04a57b5d76225639418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 15 May 2024 15:11:50 +0200 Subject: [PATCH 04/13] refactor: use imported aioeapi module --- aioeapi/config_session.py | 8 ++++---- aioeapi/device.py | 11 +++++++---- anta/cli/exec/utils.py | 2 +- anta/device.py | 9 +++++---- tests/lib/fixture.py | 2 +- tests/units/test_device.py | 8 ++++---- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/aioeapi/config_session.py b/aioeapi/config_session.py index 2327d762e..2a654c781 100644 --- a/aioeapi/config_session.py +++ b/aioeapi/config_session.py @@ -189,7 +189,7 @@ async def push(self, content: list[str] | str, *, replace: bool = False) -> None # prepare the initial set of command to enter the config session and # rollback clean if the `replace` argument is True. - commands = [self._cli_config_session] + commands: list[str | dict[str, Any]] = [self._cli_config_session] if replace: commands.append(self.CLI_CFG_FACTORY_RESET) @@ -244,7 +244,7 @@ async def diff(self) -> str: ---------- * https://www.gnu.org/software/diffutils/manual/diffutils.txt """ - return await self._cli(f"show session-config named {self.name} diffs", ofmt="text")# type: ignore[return-value] # text outformat returns 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: """ @@ -268,12 +268,12 @@ async def load_file(self, filename: str, *, replace: bool = False) -> None: If there are any issues with loading the configuration file then a RuntimeError is raised with the error messages content. """ - commands = [self._cli_config_session] + commands: list[str | dict[str, Any]] = [self._cli_config_session] if replace: commands.append(self.CLI_CFG_FACTORY_RESET) commands.append(f"copy {filename} session-config") - res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]] + res: list[dict[str, Any]] = await self._cli(commands=commands) # type: ignore[assignment] # JSON outformat of multiple commands returns list[dict[str, Any]] checks_re = re.compile(r"error|abort|invalid", flags=re.I) messages = res[-1]["messages"] diff --git a/aioeapi/device.py b/aioeapi/device.py index 0cea1799d..4da52ea51 100644 --- a/aioeapi/device.py +++ b/aioeapi/device.py @@ -6,7 +6,7 @@ from __future__ import annotations from socket import getservbyname -from typing import Any +from typing import TYPE_CHECKING, Any # ----------------------------------------------------------------------------- # Public Imports @@ -20,6 +20,9 @@ from .config_session import SessionConfig from .errors import EapiCommandError +if TYPE_CHECKING: + from collections.abc import Sequence + # ----------------------------------------------------------------------------- # Exports # ----------------------------------------------------------------------------- @@ -110,8 +113,8 @@ async def check_connection(self) -> bool: async def cli( # noqa: PLR0913 self, - command: str | None = None, - commands: list[str] | None = None, + command: str | dict[str, Any] | None = None, + commands: Sequence[str | dict[str, Any]] | None = None, ofmt: str | None = None, version: int | str | None = "latest", *, @@ -184,7 +187,7 @@ async def cli( # noqa: PLR0913 def _jsonrpc_command( # noqa: PLR0913 self, - commands: list[str] | None = None, + commands: Sequence[str | dict[str, Any]] | None = None, ofmt: str | None = None, version: int | str | None = "latest", *, diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index 5a28912e1..2c9ffd3b6 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -14,10 +14,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal -from aioeapi import EapiCommandError from click.exceptions import UsageError from httpx import ConnectError, HTTPError +from aioeapi import EapiCommandError from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand diff --git a/anta/device.py b/anta/device.py index d517b8fb0..8c16a020b 100644 --- a/anta/device.py +++ b/anta/device.py @@ -18,7 +18,8 @@ from asyncssh import SSHClientConnection, SSHClientConnectionOptions from httpx import ConnectError, HTTPError, TimeoutException -from anta import __DEBUG__, aioeapi +import aioeapi +from anta import __DEBUG__ from anta.logger import anta_log_exception, exc_to_str from anta.models import AntaCommand @@ -316,7 +317,7 @@ async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function ---- command: the AntaCommand to collect. """ - commands: list[dict[str, Any]] = [] + commands: list[dict[str, str | int]] = [] if self.enable and self._enable_password is not None: commands.append( { @@ -329,11 +330,11 @@ async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function commands.append({"cmd": "enable"}) commands += [{"cmd": command.command, "revision": command.revision}] if command.revision else [{"cmd": command.command}] try: - response: list[dict[str, Any]] = await self._session.cli( + response: list[dict[str, Any] | str] = await self._session.cli( commands=commands, ofmt=command.ofmt, version=command.version, - ) + ) # type: ignore[assignment] # multiple commands returns a list # Do not keep response of 'enable' command command.output = response[-1] except aioeapi.EapiCommandError as e: diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 43fb60a84..f0f25b838 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -13,7 +13,7 @@ import pytest from click.testing import CliRunner, Result -from anta import aioeapi +import aioeapi from anta.cli.console import console from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory diff --git a/tests/units/test_device.py b/tests/units/test_device.py index c901a3dd8..cb5d69928 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -15,7 +15,7 @@ from asyncssh import SSHClientConnection, SSHClientConnectionOptions from rich import print as rprint -from anta import aioeapi +import aioeapi from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand from tests.lib.fixture import COMMAND_OUTPUT @@ -705,9 +705,9 @@ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[di """Test AsyncEOSDevice.refresh().""" with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]): await async_device.refresh() - async_device._session.check_connection.assert_called_once() + async_device._session.check_connection.assert_called_once() # type: ignore[attr-defined] # aioeapi.Device.check_connection is patched if expected["is_online"]: - async_device._session.cli.assert_called_once() + async_device._session.cli.assert_called_once() # type: ignore[attr-defined] # aioeapi.Device.cli is patched assert async_device.is_online == expected["is_online"] assert async_device.established == expected["established"] assert async_device.hw_model == expected["hw_model"] @@ -740,7 +740,7 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A commands.append({"cmd": cmd.command, "revision": cmd.revision}) else: commands.append({"cmd": cmd.command}) - async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version) + async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version) # type: ignore[attr-defined] # aioeapi.Device.cli is patched # pylint: disable=line-too-long assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] From 074e5cbf9ddded2462c7dc777dc22c1071cd5d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 15 May 2024 17:39:46 +0200 Subject: [PATCH 05/13] feat: add collection_id to AntaDevice.collect() --- anta/device.py | 30 ++++++++++++++++-------------- anta/models.py | 2 +- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/anta/device.py b/anta/device.py index 8c16a020b..14aee3db7 100644 --- a/anta/device.py +++ b/anta/device.py @@ -117,7 +117,7 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: yield "disable_cache", self.cache is None @abstractmethod - async def _collect(self, command: AntaCommand) -> None: + async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect device command output. This abstract coroutine can be used to implement any command collection method @@ -132,11 +132,11 @@ async def _collect(self, command: AntaCommand) -> None: Args: ---- - command: the command to collect - + command: The command to collect. + collection_id: An identifier that will used to build the eAPI request ID. """ - async def collect(self, command: AntaCommand) -> None: + async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: """Collect the output for a specified command. When caching is activated on both the device and the command, @@ -149,8 +149,8 @@ async def collect(self, command: AntaCommand) -> None: Args: ---- - command (AntaCommand): The command to process. - + command: The command to collect. + collection_id: An identifier that will used to build the eAPI request ID. """ # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # https://github.com/pylint-dev/pylint/issues/7258 @@ -162,20 +162,20 @@ async def collect(self, command: AntaCommand) -> None: logger.debug("Cache hit for %s on %s", command.command, self.name) command.output = cached_output else: - await self._collect(command=command) + await self._collect(command=command, collection_id=collection_id) await self.cache.set(command.uid, command.output) # pylint: disable=no-member else: - await self._collect(command=command) + await self._collect(command=command, collection_id=collection_id) - async def collect_commands(self, commands: list[AntaCommand]) -> None: + async def collect_commands(self, commands: list[AntaCommand], *, collection_id: str | None = None) -> None: """Collect multiple commands. Args: ---- - commands: the commands to collect - + commands: The commands to collect. + collection_id: An identifier that will used to build the eAPI request ID. """ - await asyncio.gather(*(self.collect(command=command) for command in commands)) + await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands)) @abstractmethod async def refresh(self) -> None: @@ -306,7 +306,7 @@ def _keys(self) -> tuple[Any, ...]: """ return (self._session.host, self._session.port) - async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function is too complex - because of many required except blocks + async def _collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: # noqa: C901 function is too complex - because of many required except blocks #pylint: disable=line-too-long """Collect device command output from EOS using aio-eapi. Supports outformat `json` and `text` as output structure. @@ -315,7 +315,8 @@ async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function Args: ---- - command: the AntaCommand to collect. + command: The command to collect. + collection_id: An identifier that will used to build the eAPI request ID. """ commands: list[dict[str, str | int]] = [] if self.enable and self._enable_password is not None: @@ -334,6 +335,7 @@ async def _collect(self, command: AntaCommand) -> None: # noqa: C901 function commands=commands, ofmt=command.ofmt, version=command.version, + req_id=f"ANTA-{collection_id}-{id(command)}" if collection_id else f"ANTA-{id(command)}", ) # type: ignore[assignment] # multiple commands returns a list # Do not keep response of 'enable' command command.output = response[-1] diff --git a/anta/models.py b/anta/models.py index 20338e7f6..eaac81ac2 100644 --- a/anta/models.py +++ b/anta/models.py @@ -530,7 +530,7 @@ async def collect(self) -> None: """Collect outputs of all commands of this test class from the device of this test instance.""" try: if self.blocked is False: - await self.device.collect_commands(self.instance_commands) + await self.device.collect_commands(self.instance_commands, collection_id=self.name) except Exception as e: # pylint: disable=broad-exception-caught # device._collect() is user-defined code. # We need to catch everything if we want the AntaTest object From c942e8afc6372232820e8655027f7eb4842200e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Wed, 15 May 2024 17:44:59 +0200 Subject: [PATCH 06/13] refactor: rename aioeapi to asynceapi to avoid conflicts --- anta/cli/exec/utils.py | 2 +- anta/device.py | 6 ++--- {aioeapi => asynceapi}/__init__.py | 0 {aioeapi => asynceapi}/aio_portcheck.py | 0 {aioeapi => asynceapi}/config_session.py | 2 +- {aioeapi => asynceapi}/device.py | 2 +- {aioeapi => asynceapi}/errors.py | 2 +- pyproject.toml | 2 +- tests/lib/fixture.py | 16 +++++++------- tests/units/test_device.py | 28 ++++++++++++------------ 10 files changed, 30 insertions(+), 30 deletions(-) rename {aioeapi => asynceapi}/__init__.py (100%) rename {aioeapi => asynceapi}/aio_portcheck.py (100%) rename {aioeapi => asynceapi}/config_session.py (99%) rename {aioeapi => asynceapi}/device.py (99%) rename {aioeapi => asynceapi}/errors.py (96%) diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index 2c9ffd3b6..f3234097d 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -17,9 +17,9 @@ from click.exceptions import UsageError from httpx import ConnectError, HTTPError -from aioeapi import EapiCommandError from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand +from asynceapi import EapiCommandError if TYPE_CHECKING: from anta.inventory import AntaInventory diff --git a/anta/device.py b/anta/device.py index 14aee3db7..9791b2bc9 100644 --- a/anta/device.py +++ b/anta/device.py @@ -18,7 +18,7 @@ from asyncssh import SSHClientConnection, SSHClientConnectionOptions from httpx import ConnectError, HTTPError, TimeoutException -import aioeapi +import asynceapi from anta import __DEBUG__ from anta.logger import anta_log_exception, exc_to_str from anta.models import AntaCommand @@ -271,7 +271,7 @@ def __init__( raise ValueError(message) self.enable = enable self._enable_password = enable_password - self._session: aioeapi.Device = aioeapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) + self._session: asynceapi.Device = asynceapi.Device(host=host, port=port, username=username, password=password, proto=proto, timeout=timeout) ssh_params: dict[str, Any] = {} if insecure: ssh_params["known_hosts"] = None @@ -339,7 +339,7 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No ) # type: ignore[assignment] # multiple commands returns a list # Do not keep response of 'enable' command command.output = response[-1] - except aioeapi.EapiCommandError as e: + except asynceapi.EapiCommandError as e: # This block catches exceptions related to EOS issuing an error. command.errors = e.errors if command.requires_privileges: diff --git a/aioeapi/__init__.py b/asynceapi/__init__.py similarity index 100% rename from aioeapi/__init__.py rename to asynceapi/__init__.py diff --git a/aioeapi/aio_portcheck.py b/asynceapi/aio_portcheck.py similarity index 100% rename from aioeapi/aio_portcheck.py rename to asynceapi/aio_portcheck.py diff --git a/aioeapi/config_session.py b/asynceapi/config_session.py similarity index 99% rename from aioeapi/config_session.py rename to asynceapi/config_session.py index 2a654c781..2f775f9f8 100644 --- a/aioeapi/config_session.py +++ b/asynceapi/config_session.py @@ -1,4 +1,4 @@ -"""aioeapi.SessionConfig definition.""" +"""asynceapi.SessionConfig definition.""" # ----------------------------------------------------------------------------- # System Imports diff --git a/aioeapi/device.py b/asynceapi/device.py similarity index 99% rename from aioeapi/device.py rename to asynceapi/device.py index 4da52ea51..a9a62dfeb 100644 --- a/aioeapi/device.py +++ b/asynceapi/device.py @@ -1,4 +1,4 @@ -"""aioeapi.Device definition.""" +"""asynceapi.Device definition.""" # ----------------------------------------------------------------------------- # System Imports # ----------------------------------------------------------------------------- diff --git a/aioeapi/errors.py b/asynceapi/errors.py similarity index 96% rename from aioeapi/errors.py rename to asynceapi/errors.py index bc3bfd6fd..a6476fbe8 100644 --- a/aioeapi/errors.py +++ b/asynceapi/errors.py @@ -1,4 +1,4 @@ -"""aioeapi module exceptions.""" +"""asynceapi module exceptions.""" from __future__ import annotations diff --git a/pyproject.toml b/pyproject.toml index 424909863..dc67db16d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ anta = "anta.cli:cli" # Tools ################################ [tool.setuptools.packages.find] -include = ["anta*", "aioeapi*"] +include = ["anta*", "asynceapi*"] namespaces = false ################################ diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index f0f25b838..51bcb3062 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -13,7 +13,7 @@ import pytest from click.testing import CliRunner, Result -import aioeapi +import asynceapi from anta.cli.console import console from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory @@ -33,7 +33,7 @@ DEVICE_NAME = "pytest" COMMAND_OUTPUT = "retrieved" -MOCK_CLI_JSON: dict[str, aioeapi.EapiCommandError | dict[str, Any]] = { +MOCK_CLI_JSON: dict[str, asynceapi.EapiCommandError | dict[str, Any]] = { "show version": { "modelName": "DCS-7280CR3-32P4-F", "version": "4.31.1F", @@ -41,7 +41,7 @@ "enable": {}, "clear counters": {}, "clear hardware counter drop": {}, - "undefined": aioeapi.EapiCommandError( + "undefined": asynceapi.EapiCommandError( passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], @@ -50,7 +50,7 @@ ), } -MOCK_CLI_TEXT: dict[str, aioeapi.EapiCommandError | str] = { +MOCK_CLI_TEXT: dict[str, asynceapi.EapiCommandError | str] = { "show version": "Arista cEOSLab", "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support": "dummy_tech-support_2023-12-01.1115.log.gz\ndummy_tech-support_2023-12-01.1015.log.gz", "bash timeout 10 ls -1t /mnt/flash/schedule/tech-support | head -1": "dummy_tech-support_2023-12-01.1115.log.gz", @@ -214,7 +214,7 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: for mock_cmd, output in mock_cli.items(): if command == mock_cmd: logger.info("Mocking command %s", mock_cmd) - if isinstance(output, aioeapi.EapiCommandError): + if isinstance(output, asynceapi.EapiCommandError): raise output return output message = f"Command '{command}' is not mocked" @@ -231,10 +231,10 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: logger.debug("Mock output %s", res) return res - # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py + # Patch asynceapi methods used by AsyncEOSDevice. See tests/units/test_device.py with ( - patch("aioeapi.device.Device.check_connection", return_value=True), - patch("aioeapi.device.Device.cli", side_effect=cli), + patch("asynceapi.device.Device.check_connection", return_value=True), + patch("asynceapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch( "asyncssh.scp", diff --git a/tests/units/test_device.py b/tests/units/test_device.py index cb5d69928..8508f0b23 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -15,7 +15,7 @@ from asyncssh import SSHClientConnection, SSHClientConnectionOptions from rich import print as rprint -import aioeapi +import asynceapi from anta.device import AntaDevice, AsyncEOSDevice from anta.models import AntaCommand from tests.lib.fixture import COMMAND_OUTPUT @@ -128,7 +128,7 @@ "expected": False, }, ] -AIOEAPI_COLLECT_DATA: list[dict[str, Any]] = [ +ASYNCEAPI_COLLECT_DATA: list[dict[str, Any]] = [ { "name": "command", "device": {}, @@ -350,12 +350,12 @@ }, }, { - "name": "aioeapi.EapiCommandError", + "name": "asynceapi.EapiCommandError", "device": {}, "command": { "command": "show version", "patch_kwargs": { - "side_effect": aioeapi.EapiCommandError( + "side_effect": asynceapi.EapiCommandError( passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], @@ -385,7 +385,7 @@ "expected": {"output": None, "errors": ["ConnectError: Cannot open port"]}, }, ] -AIOEAPI_COPY_DATA: list[dict[str, Any]] = [ +ASYNCEAPI_COPY_DATA: list[dict[str, Any]] = [ { "name": "from", "device": {}, @@ -509,12 +509,12 @@ "expected": {"is_online": True, "established": False, "hw_model": None}, }, { - "name": "aioeapi.EapiCommandError", + "name": "asynceapi.EapiCommandError", "device": {}, "patch_kwargs": ( {"return_value": True}, { - "side_effect": aioeapi.EapiCommandError( + "side_effect": asynceapi.EapiCommandError( passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], @@ -705,9 +705,9 @@ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[di """Test AsyncEOSDevice.refresh().""" with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]): await async_device.refresh() - async_device._session.check_connection.assert_called_once() # type: ignore[attr-defined] # aioeapi.Device.check_connection is patched + async_device._session.check_connection.assert_called_once() # type: ignore[attr-defined] # asynceapi.Device.check_connection is patched if expected["is_online"]: - async_device._session.cli.assert_called_once() # type: ignore[attr-defined] # aioeapi.Device.cli is patched + async_device._session.cli.assert_called_once() # type: ignore[attr-defined] # asynceapi.Device.cli is patched assert async_device.is_online == expected["is_online"] assert async_device.established == expected["established"] assert async_device.hw_model == expected["hw_model"] @@ -715,8 +715,8 @@ async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[di @pytest.mark.asyncio() @pytest.mark.parametrize( ("async_device", "command", "expected"), - ((d["device"], d["command"], d["expected"]) for d in AIOEAPI_COLLECT_DATA), - ids=generate_test_ids_list(AIOEAPI_COLLECT_DATA), + ((d["device"], d["command"], d["expected"]) for d in ASYNCEAPI_COLLECT_DATA), + ids=generate_test_ids_list(ASYNCEAPI_COLLECT_DATA), indirect=["async_device"], ) async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None: @@ -740,15 +740,15 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A commands.append({"cmd": cmd.command, "revision": cmd.revision}) else: commands.append({"cmd": cmd.command}) - async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version) # type: ignore[attr-defined] # aioeapi.Device.cli is patched # pylint: disable=line-too-long + async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version) # type: ignore[attr-defined] # asynceapi.Device.cli is patched # pylint: disable=line-too-long assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] @pytest.mark.asyncio() @pytest.mark.parametrize( ("async_device", "copy"), - ((d["device"], d["copy"]) for d in AIOEAPI_COPY_DATA), - ids=generate_test_ids_list(AIOEAPI_COPY_DATA), + ((d["device"], d["copy"]) for d in ASYNCEAPI_COPY_DATA), + ids=generate_test_ids_list(ASYNCEAPI_COPY_DATA), indirect=["async_device"], ) async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None: From 1db53a7d600c5e15849cc853aeb8aa630279beb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Fri, 17 May 2024 15:54:07 +0200 Subject: [PATCH 07/13] test: update unit tests for collection id --- tests/lib/fixture.py | 2 +- tests/units/cli/exec/test_utils.py | 7 ++++--- tests/units/test_device.py | 9 +++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 51bcb3062..17943edc3 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -62,7 +62,7 @@ def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: """Return an AntaDevice instance with mocked abstract method.""" - def _collect(command: AntaCommand) -> None: + def _collect(command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 #pylint: disable=unused-argument command.output = COMMAND_OUTPUT kwargs = {"name": DEVICE_NAME, "hw_model": DEVICE_HW_MODEL} diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py index 455568bb8..18ae1a092 100644 --- a/tests/units/cli/exec/test_utils.py +++ b/tests/units/cli/exec/test_utils.py @@ -85,19 +85,18 @@ async def mock_connect_inventory() -> None: device.established = inventory_state[name].get("established", device.is_online) device.hw_model = inventory_state[name].get("hw_model", "dummy") - async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: + async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: Any) -> None: # noqa: ARG001, ANN401 #pylint: disable=unused-argument """Mock collect coroutine.""" command.output = per_device_command_output.get(self.name, "") # Need to patch the child device class with ( - patch("anta.device.AsyncEOSDevice.collect", side_effect=dummy_collect, autospec=True) as mocked_collect, + patch("anta.device.AsyncEOSDevice.collect", side_effect=collect, autospec=True) as mocked_collect, patch( "anta.inventory.AntaInventory.connect_inventory", side_effect=mock_connect_inventory, ) as mocked_connect_inventory, ): - mocked_collect.side_effect = dummy_collect await clear_counters_utils(test_inventory, tags=tags) mocked_connect_inventory.assert_awaited_once() @@ -117,6 +116,7 @@ async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: output=per_device_command_output.get(device.name, ""), errors=[], ), + collection_id=None, ), ) if device.hw_model not in ["cEOSLab", "vEOS-lab"]: @@ -130,6 +130,7 @@ async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: ofmt="json", output=per_device_command_output.get(device.name, ""), ), + collection_id=None, ), ) mocked_collect.assert_has_awaits(calls) diff --git a/tests/units/test_device.py b/tests/units/test_device.py index 8508f0b23..e8a0c5f86 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -644,7 +644,7 @@ async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], e assert current_cached_data == COMMAND_OUTPUT assert device.cache.hit_miss_ratio["hits"] == 1 else: # command is not allowed to use cache - device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access + device._collect.assert_called_once_with(command=command, collection_id=None) # type: ignore[attr-defined] # pylint: disable=protected-access assert command.output == COMMAND_OUTPUT if expected_data["cache_hit"] is True: assert current_cached_data == cached_output @@ -652,7 +652,7 @@ async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], e assert current_cached_data is None else: # device is disabled assert device.cache is None - device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access + device._collect.assert_called_once_with(command=command, collection_id=None) # type: ignore[attr-defined] # pylint: disable=protected-access @pytest.mark.parametrize(("device", "expected"), CACHE_STATS_DATA, indirect=["device"]) def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None: @@ -724,7 +724,8 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A """Test AsyncEOSDevice._collect().""" cmd = AntaCommand(command=command["command"], revision=command["revision"]) if "revision" in command else AntaCommand(command=command["command"]) with patch.object(async_device._session, "cli", **command["patch_kwargs"]): - await async_device.collect(cmd) + collection_id = "pytest" + await async_device.collect(cmd, collection_id=collection_id) commands: list[dict[str, Any]] = [] if async_device.enable and async_device._enable_password is not None: commands.append( @@ -740,7 +741,7 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A commands.append({"cmd": cmd.command, "revision": cmd.revision}) else: commands.append({"cmd": cmd.command}) - async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version) # type: ignore[attr-defined] # asynceapi.Device.cli is patched # pylint: disable=line-too-long + async_device._session.cli.assert_called_once_with(commands=commands, ofmt=cmd.ofmt, version=cmd.version, req_id=f"ANTA-{collection_id}-{id(cmd)}") # type: ignore[attr-defined] # asynceapi.Device.cli is patched # pylint: disable=line-too-long assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] From 52945ee0d87458eed58dd6ed36e5c7c35f6c0f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Fri, 17 May 2024 15:59:07 +0200 Subject: [PATCH 08/13] doc: add EOS troubleshooting section --- asynceapi/config_session.py | 2 +- docs/troubleshooting.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py index 2f775f9f8..df3a85595 100644 --- a/asynceapi/config_session.py +++ b/asynceapi/config_session.py @@ -28,7 +28,7 @@ class SessionConfig: """ Send configuration to a device using the EOS session mechanism. - This is the preferred way of managing configuraiton changes. + This is the preferred way of managing configuration changes. Notes ----- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 596aad67c..b1f1c127b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -77,3 +77,14 @@ Example: ```bash ANTA_DEBUG=true anta -l DEBUG --log-file anta.log nrfu --enable --username username --password arista --inventory inventory.yml -c nrfu.yml text ``` + +### Troubleshooting on EOS + +ANTA is using a specific ID in eAPI requests towards EOS. This allows for easier eAPI requests debug on the device using EOS configuration `trace CapiApp setting UwsgiRequestContext/4,CapiUwsgiServer/4` to set up CapiApp agent logs. + +Then, you can view agent logs using: +``` +bash tail -f /var/log/agents/CapiApp-* + +2024-05-15 15:32:54.056166 1429 UwsgiRequestContext 4 request content b'{"jsonrpc": "2.0", "method": "runCmds", "params": {"version": "latest", "cmds": [{"cmd": "show ip route vrf default 10.255.0.3", "revision": 4}], "format": "json", "autoComplete": false, "expandAliases": false}, "id": "ANTA-VerifyRoutingTableEntry-132366530677328"}' +``` From 41604eb8a7c371b936f8c2a2e9244ed45f727a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthieu=20T=C3=A2che?= Date: Fri, 17 May 2024 16:01:52 +0200 Subject: [PATCH 09/13] refactor: rename functions in anta.cli.exec.utils module --- anta/cli/exec/commands.py | 8 ++++---- anta/cli/exec/utils.py | 4 ++-- tests/units/cli/exec/test_utils.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 9842cc882..531614abd 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -16,7 +16,7 @@ from yaml import safe_load from anta.cli.console import console -from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech +from anta.cli.exec import utils from anta.cli.utils import inventory_options if TYPE_CHECKING: @@ -29,7 +29,7 @@ @inventory_options def clear_counters(inventory: AntaInventory, tags: set[str] | None) -> None: """Clear counter statistics on EOS devices.""" - asyncio.run(clear_counters_utils(inventory, tags=tags)) + asyncio.run(utils.clear_counters(inventory, tags=tags)) @click.command() @@ -62,7 +62,7 @@ def snapshot(inventory: AntaInventory, tags: set[str] | None, commands_list: Pat except FileNotFoundError: logger.error("Error reading %s", commands_list) sys.exit(1) - asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags)) + asyncio.run(utils.collect_commands(inventory, eos_commands, output, tags=tags)) @click.command() @@ -98,4 +98,4 @@ def collect_tech_support( configure: bool, ) -> None: """Collect scheduled tech-support from EOS devices.""" - asyncio.run(collect_scheduled_show_tech(inventory, output, configure=configure, tags=tags, latest=latest)) + asyncio.run(utils.collect_show_tech(inventory, output, configure=configure, tags=tags, latest=latest)) diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index f3234097d..7033beb35 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -29,7 +29,7 @@ logger = logging.getLogger(__name__) -async def clear_counters_utils(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None: +async def clear_counters(anta_inventory: AntaInventory, tags: set[str] | None = None) -> None: """Clear counters.""" async def clear(dev: AntaDevice) -> None: @@ -94,7 +94,7 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex logger.error("Error when collecting commands: %s", str(r)) -async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None: +async def collect_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: set[str] | None = None, latest: int | None = None) -> None: """Collect scheduled show-tech on devices.""" async def collect(device: AntaDevice) -> None: diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py index 18ae1a092..ad1a78ab1 100644 --- a/tests/units/cli/exec/test_utils.py +++ b/tests/units/cli/exec/test_utils.py @@ -11,7 +11,7 @@ import pytest from anta.cli.exec.utils import ( - clear_counters_utils, + clear_counters, ) from anta.models import AntaCommand @@ -69,14 +69,14 @@ ), ], ) -async def test_clear_counters_utils( +async def test_clear_counters( caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory, inventory_state: dict[str, Any], per_device_command_output: dict[str, Any], tags: set[str] | None, ) -> None: - """Test anta.cli.exec.utils.clear_counters_utils.""" + """Test anta.cli.exec.utils.clear_counters.""" async def mock_connect_inventory() -> None: """Mock connect_inventory coroutine.""" @@ -97,7 +97,7 @@ async def collect(self: AntaDevice, command: AntaCommand, *args: Any, **kwargs: side_effect=mock_connect_inventory, ) as mocked_connect_inventory, ): - await clear_counters_utils(test_inventory, tags=tags) + await clear_counters(test_inventory, tags=tags) mocked_connect_inventory.assert_awaited_once() devices_established = test_inventory.get_inventory(established_only=True, tags=tags).devices From 5772bab214b555da1607d7866832fd07e24dd253 Mon Sep 17 00:00:00 2001 From: Thomas Grimonet Date: Fri, 17 May 2024 17:02:27 +0200 Subject: [PATCH 10/13] chore: Add NOTICE file --- NOTICE | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 NOTICE diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..50119523a --- /dev/null +++ b/NOTICE @@ -0,0 +1,28 @@ +Apache ANTA Project + +Copyright 2024 Arista Networks + +This product includes software developed at Arista Networks. + +------------------------------------------------------------------------ + +This product includes software developed by contributors from the +following projects, which are also licensed under the Apache License, Version 2.0: + +1. aio-eapi + - Copyright 2024 Jeremy Schulman + - URL: https://github.com/jeremyschulman/aio-eapi + +------------------------------------------------------------------------ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file From 702397c646c6210c4d47dd45ab9cc0f604e27225 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 17 May 2024 17:03:36 +0200 Subject: [PATCH 11/13] ci: Add asynceapi to pre-commit watch --- .pre-commit-config.yaml | 2 +- asynceapi/__init__.py | 3 +++ asynceapi/aio_portcheck.py | 9 ++++++--- asynceapi/config_session.py | 8 ++++++-- asynceapi/device.py | 18 +++++++++++------- asynceapi/errors.py | 6 +++++- 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c86d39f6..bdfb5abf1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks -files: ^(anta|docs|scripts|tests)/ +files: ^(anta|docs|scripts|tests|asynceapi)/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/asynceapi/__init__.py b/asynceapi/__init__.py index decd3e16b..d6586cf9b 100644 --- a/asynceapi/__init__.py +++ b/asynceapi/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) 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. # Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi """Arista EOS eAPI asyncio client.""" diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py index 0d43e46a6..79f4562fa 100644 --- a/asynceapi/aio_portcheck.py +++ b/asynceapi/aio_portcheck.py @@ -1,3 +1,7 @@ +# Copyright (c) 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. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi """Utility function to check if a port is open.""" # ----------------------------------------------------------------------------- # System Imports @@ -33,7 +37,7 @@ async def port_check_url(url: URL, timeout: int = 5) -> bool: """ Open the port designated by the URL given the timeout in seconds. - If the port is avaialble then return True; False otherwise. + If the port is available then return True; False otherwise. Parameters ---------- @@ -51,5 +55,4 @@ async def port_check_url(url: URL, timeout: int = 5) -> bool: except TimeoutError: return False - else: - return True + return True diff --git a/asynceapi/config_session.py b/asynceapi/config_session.py index df3a85595..4054f14bf 100644 --- a/asynceapi/config_session.py +++ b/asynceapi/config_session.py @@ -1,3 +1,7 @@ +# Copyright (c) 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. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi """asynceapi.SessionConfig definition.""" # ----------------------------------------------------------------------------- @@ -125,7 +129,7 @@ async def status(self) -> dict[str, Any] | None: Dict instance of the session status. If the session does not exist, then this method will return None. - The native eAPI results from JSON output, see exmaple: + The native eAPI results from JSON output, see example: Examples -------- @@ -256,7 +260,7 @@ async def load_file(self, filename: str, *, replace: bool = False) -> None: ---------- filename: The name of the configuration file. The caller is required to - specify the filesystem, for exmaple, the + specify the filesystem, for example, the filename="flash:thisfile.cfg" replace: diff --git a/asynceapi/device.py b/asynceapi/device.py index a9a62dfeb..04ec3ab7c 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -1,3 +1,7 @@ +# Copyright (c) 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. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi """asynceapi.Device definition.""" # ----------------------------------------------------------------------------- # System Imports @@ -50,7 +54,7 @@ class Device(httpx.AsyncClient): EAPI_OFMT_OPTIONS = ("json", "text") EAPI_DEFAULT_OFMT = "json" - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments self, host: str | None = None, username: str | None = None, @@ -102,7 +106,7 @@ async def check_connection(self) -> bool: """ 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 involing cli commands, + It is recommended that a Caller checks the connection before involving cli commands, but this step is not required. Returns @@ -111,7 +115,7 @@ async def check_connection(self) -> bool: """ return await port_check_url(self.base_url) - async def cli( # noqa: PLR0913 + async def cli( # noqa: PLR0913 # pylint: disable=too-many-arguments self, command: str | dict[str, Any] | None = None, commands: Sequence[str | dict[str, Any]] | None = None, @@ -133,12 +137,12 @@ async def cli( # noqa: PLR0913 commands: A list of commands to execute; results in a list of output responses ofmt: - Either 'json' or 'text'; indicates the output fromat for the CLI commands. + Either 'json' or 'text'; indicates the output format for the CLI commands. version: By default the eAPI will use "version 1" for all API object models. This driver will, by default, always set version to "latest" so that the behavior matches the CLI of the device. The caller can - override the "latest" behavior by explicity setting the version. + override the "latest" behavior by explicitly setting the version. suppress_error: When not False, then if the execution of the command would-have raised an EapiCommandError, rather than raising this exception this @@ -167,7 +171,7 @@ async def cli( # noqa: PLR0913 Returns ------- - One or List of output respones, per the description above. + One or List of output responses, per the description above. """ if not any((command, commands)): msg = "Required 'command' or 'commands'" @@ -185,7 +189,7 @@ async def cli( # noqa: PLR0913 return None raise - def _jsonrpc_command( # noqa: PLR0913 + def _jsonrpc_command( # noqa: PLR0913 # pylint: disable=too-many-arguments self, commands: Sequence[str | dict[str, Any]] | None = None, ofmt: str | None = None, diff --git a/asynceapi/errors.py b/asynceapi/errors.py index a6476fbe8..614427a1a 100644 --- a/asynceapi/errors.py +++ b/asynceapi/errors.py @@ -1,3 +1,7 @@ +# Copyright (c) 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. +# Initially written by Jeremy Schulman at https://github.com/jeremyschulman/aio-eapi """asynceapi module exceptions.""" from __future__ import annotations @@ -20,7 +24,7 @@ class EapiCommandError(RuntimeError): not_exec: a list of commands that were not executed """ - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # noqa: PLR0913 + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: # noqa: PLR0913 # pylint: disable=too-many-arguments """Initialize for the EapiCommandError exception.""" self.failed = failed self.errmsg = errmsg From ea7600690780d9d1611093181ac158bae1da1c95 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 17 May 2024 17:05:22 +0200 Subject: [PATCH 12/13] chore: Fix notice --- NOTICE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 50119523a..fbbee8d43 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -Apache ANTA Project +ANTA Project Copyright 2024 Arista Networks @@ -25,4 +25,4 @@ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file +limitations under the License. From 3ad82502ebe7a2459859891997aff98aa63c8745 Mon Sep 17 00:00:00 2001 From: gmuloc Date: Fri, 17 May 2024 17:11:20 +0200 Subject: [PATCH 13/13] chore: Fix PR comments --- anta/device.py | 8 ++++---- docs/README.md | 2 ++ docs/troubleshooting.md | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/anta/device.py b/anta/device.py index 9791b2bc9..f0ec6a00c 100644 --- a/anta/device.py +++ b/anta/device.py @@ -133,7 +133,7 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No Args: ---- command: The command to collect. - collection_id: An identifier that will used to build the eAPI request ID. + collection_id: An identifier used to build the eAPI request ID. """ async def collect(self, command: AntaCommand, *, collection_id: str | None = None) -> None: @@ -150,7 +150,7 @@ async def collect(self, command: AntaCommand, *, collection_id: str | None = Non Args: ---- command: The command to collect. - collection_id: An identifier that will used to build the eAPI request ID. + collection_id: An identifier used to build the eAPI request ID. """ # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # https://github.com/pylint-dev/pylint/issues/7258 @@ -173,7 +173,7 @@ async def collect_commands(self, commands: list[AntaCommand], *, collection_id: Args: ---- commands: The commands to collect. - collection_id: An identifier that will used to build the eAPI request ID. + collection_id: An identifier used to build the eAPI request ID. """ await asyncio.gather(*(self.collect(command=command, collection_id=collection_id) for command in commands)) @@ -316,7 +316,7 @@ async def _collect(self, command: AntaCommand, *, collection_id: str | None = No Args: ---- command: The command to collect. - collection_id: An identifier that will used to build the eAPI request ID. + collection_id: An identifier used to build the eAPI request ID. """ commands: list[dict[str, str | int]] = [] if self.enable and self._enable_password is not None: diff --git a/docs/README.md b/docs/README.md index ce67bbeed..378867faf 100755 --- a/docs/README.md +++ b/docs/README.md @@ -85,4 +85,6 @@ Contributions are welcome. Please refer to the [contribution guide](contribution ## Credits +Thank you to [Jeremy Schulman](https://github.com/jeremyschulman) for [aio-eapi](https://github.com/jeremyschulman/aio-eapi/tree/main/aioeapi). + Thank you to [Angélique Phillipps](https://github.com/aphillipps), [Colin MacGiollaEáin](https://github.com/colinmacgiolla), [Khelil Sator](https://github.com/ksator), [Matthieu Tache](https://github.com/mtache), [Onur Gashi](https://github.com/onurgashi), [Paul Lavelle](https://github.com/paullavelle), [Guillaume Mulocher](https://github.com/gmuloc) and [Thomas Grimonet](https://github.com/titom73) for their contributions and guidances. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b1f1c127b..f27de7aee 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -80,10 +80,10 @@ ANTA_DEBUG=true anta -l DEBUG --log-file anta.log nrfu --enable --username usern ### Troubleshooting on EOS -ANTA is using a specific ID in eAPI requests towards EOS. This allows for easier eAPI requests debug on the device using EOS configuration `trace CapiApp setting UwsgiRequestContext/4,CapiUwsgiServer/4` to set up CapiApp agent logs. +ANTA is using a specific ID in eAPI requests towards EOS. This allows for easier eAPI requests debugging on the device using EOS configuration `trace CapiApp setting UwsgiRequestContext/4,CapiUwsgiServer/4` to set up CapiApp agent logs. Then, you can view agent logs using: -``` +```bash bash tail -f /var/log/agents/CapiApp-* 2024-05-15 15:32:54.056166 1429 UwsgiRequestContext 4 request content b'{"jsonrpc": "2.0", "method": "runCmds", "params": {"version": "latest", "cmds": [{"cmd": "show ip route vrf default 10.255.0.3", "revision": 4}], "format": "json", "autoComplete": false, "expandAliases": false}, "id": "ANTA-VerifyRoutingTableEntry-132366530677328"}'