diff --git a/anta/device.py b/anta/device.py index d7d2b0de2..a060e2653 100644 --- a/anta/device.py +++ b/anta/device.py @@ -450,12 +450,17 @@ async def refresh(self) -> None: """Update attributes of an AsyncEOSDevice instance. This coroutine must update the following attributes of AsyncEOSDevice: - - is_online: When a device IP is reachable and a port can be open + - is_online: When a device eAPI HTTP endpoint is accessible - established: When a command execution succeeds - hw_model: The hardware model of the device """ logger.debug("Refreshing device %s", self.name) - self.is_online = await self._session.check_connection() + try: + self.is_online = await self._session.check_connection() + except HTTPError as e: + self.is_online = False + logger.warning("Could not connect to device %s: %s", self.name, e) + if self.is_online: show_version = AntaCommand(command="show version") await self._collect(show_version) @@ -469,8 +474,6 @@ async def refresh(self) -> None: # and it is nice to get a meaninfule error message elif self.hw_model == "": logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name) - else: - logger.warning("Could not connect to device %s: cannot open eAPI port", self.name) self.established = bool(self.is_online and self.hw_model) diff --git a/asynceapi/aio_portcheck.py b/asynceapi/aio_portcheck.py deleted file mode 100644 index 0cab94cb3..000000000 --- a/asynceapi/aio_portcheck.py +++ /dev/null @@ -1,63 +0,0 @@ -# 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 -# ----------------------------------------------------------------------------- - -from __future__ import annotations - -import asyncio -import socket -from typing import TYPE_CHECKING - -# ----------------------------------------------------------------------------- -# Public Imports -# ----------------------------------------------------------------------------- - -if TYPE_CHECKING: - from httpx import URL - -# ----------------------------------------------------------------------------- -# Exports -# ----------------------------------------------------------------------------- - -__all__ = ["port_check_url"] - -# ----------------------------------------------------------------------------- -# -# CODE BEGINS -# -# ----------------------------------------------------------------------------- - - -async def port_check_url(url: URL, timeout: int = 5) -> bool: - """ - Open the port designated by the URL given the timeout in seconds. - - Parameters - ---------- - url - The URL that provides the target system. - timeout - Time to await for the port to open in seconds. - - Returns - ------- - bool - If the port is available then return True; False otherwise. - """ - 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() - - except TimeoutError: - return False - return True diff --git a/asynceapi/device.py b/asynceapi/device.py index 933ae649c..671dfd1a2 100644 --- a/asynceapi/device.py +++ b/asynceapi/device.py @@ -20,7 +20,6 @@ # ----------------------------------------------------------------------------- # Private Imports # ----------------------------------------------------------------------------- -from .aio_portcheck import port_check_url from .config_session import SessionConfig from .errors import EapiCommandError @@ -51,6 +50,7 @@ class Device(httpx.AsyncClient): """ auth = None + EAPI_COMMAND_API_URL = "/command-api" EAPI_OFMT_OPTIONS = ("json", "text") EAPI_DEFAULT_OFMT = "json" @@ -112,7 +112,7 @@ def __init__( async def check_connection(self) -> bool: """ - Check the target device to ensure that the eAPI port is open and accepting connections. + Check the target device eAPI HTTP endpoint with a HEAD request. It is recommended that a Caller checks the connection before involving cli commands, but this step is not required. @@ -120,9 +120,12 @@ async def check_connection(self) -> bool: Returns ------- bool - True when the device eAPI is accessible, False otherwise. + True when the device eAPI HTTP endpoint is accessible (2xx status code), + otherwise an HTTPStatusError exception is raised. """ - return await port_check_url(self.base_url) + response = await self.head(self.EAPI_COMMAND_API_URL, timeout=5) + response.raise_for_status() + return True async def cli( # noqa: PLR0913 self, @@ -283,7 +286,7 @@ async def jsonrpc_exec(self, jsonrpc: dict[str, Any]) -> list[dict[str, Any] | s 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 = await self.post(self.EAPI_COMMAND_API_URL, json=jsonrpc) res.raise_for_status() body = res.json() diff --git a/tests/conftest.py b/tests/conftest.py index 7858e401c..b547c7614 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,8 @@ # that can be found in the LICENSE file. """See https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files.""" -import asyncio from collections.abc import Iterator from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch import pytest import respx @@ -42,7 +40,8 @@ def inventory(request: pytest.FixtureRequest) -> Iterator[AntaInventory]: ) if reachable: # This context manager makes all devices reachable - with patch("asyncio.open_connection", AsyncMock(spec=asyncio.open_connection, return_value=(Mock(), Mock()))), respx.mock: + with respx.mock: + respx.head(path="/command-api") respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show version").respond( json={ "result": [ @@ -54,5 +53,6 @@ def inventory(request: pytest.FixtureRequest) -> Iterator[AntaInventory]: ) yield inv else: - with patch("asyncio.open_connection", AsyncMock(spec=asyncio.open_connection, side_effect=TimeoutError)): + with respx.mock: + respx.head(path="/command-api").respond(status_code=401) yield inv diff --git a/tests/units/test_device.py b/tests/units/test_device.py index faf614481..4be44a580 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -343,29 +343,8 @@ pytest.param( {}, ( - {"return_value": False}, - { - "return_value": { - "mfgName": "Arista", - "modelName": "DCS-7280CR3-32P4-F", - "hardwareRevision": "11.00", - "serialNumber": "JPE19500066", - "systemMacAddress": "fc:bd:67:3d:13:c5", - "hwMacAddress": "fc:bd:67:3d:13:c5", - "configMacAddress": "00:00:00:00:00:00", - "version": "4.31.1F-34361447.fraserrel (engineering build)", - "architecture": "x86_64", - "internalVersion": "4.31.1F-34361447.fraserrel", - "internalBuildId": "4940d112-a2fc-4970-8b5a-a16cd03fd08c", - "imageFormatVersion": "3.0", - "imageOptimization": "Default", - "bootupTimestamp": 1700729434.5892005, - "uptime": 20666.78, - "memTotal": 8099732, - "memFree": 4989568, - "isIntlVersion": False, - } - }, + {"side_effect": HTTPError(message="Unauthorized")}, + {}, ), {"is_online": False, "established": False, "hw_model": None}, id="is not online",