From b88b57aff93c95ae07f8a2f05a6a577b39cb1f1f Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 14 Apr 2024 11:24:45 +0200 Subject: Adding upstream version 0.6.3. Signed-off-by: Daniel Baumann --- aioeapi/__init__.py | 3 + aioeapi/aio_portcheck.py | 56 +++++++++ aioeapi/config_session.py | 259 ++++++++++++++++++++++++++++++++++++++++++ aioeapi/device.py | 283 ++++++++++++++++++++++++++++++++++++++++++++++ aioeapi/errors.py | 30 +++++ 5 files changed, 631 insertions(+) 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 (limited to 'aioeapi') diff --git a/aioeapi/__init__.py b/aioeapi/__init__.py new file mode 100644 index 0000000..723a209 --- /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 0000000..e82875d --- /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 0000000..f1beb5e --- /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 0000000..ae2aef5 --- /dev/null +++ b/aioeapi/device.py @@ -0,0 +1,283 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from typing import Optional, List, Union, Dict, 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[Union[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). + # --------------------------------------------------------------------- + + 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], + 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 0000000..1c415b2 --- /dev/null +++ b/aioeapi/errors.py @@ -0,0 +1,30 @@ +import httpx + + +class EapiCommandError(RuntimeError): + """ + Exception class for EAPI command errors + + Attributes + ---------- + failed: str - the failed command + errmsg: str - a description of the failure reason + 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, errmsg: str, passed, not_exec): + """Initializer for the EapiCommandError exception""" + self.failed = failed + self.errmsg = errmsg + self.passed = passed + self.not_exec = not_exec + super(EapiCommandError, self).__init__() + + def __str__(self): + """returns the error message associated with the exception""" + return self.errmsg + + +# alias for exception during sending-receiving +EapiTransportError = httpx.HTTPStatusError -- cgit v1.2.3