Repository: aristanetworks/anta
Pull request #851 (2024-10-04 16:11:53)

feat(anta)!: Use HTTP HEAD request instead of a TCP connection to check if device is online
# Description

Use HTTP HEAD request to check connection to a device instead of pure L3 check.
This allows to check connectivity on devices behind a reverse proxy, so rather than checking the reverse proxy is alive using HTTP the request will be forwarded to the right device.

The change is as close to the original as possible, but we could potentially check if the credentials are correct as well.

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have run pre-commit for code linting and typing (`pre-commit run`)
- [ ] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes (`tox -e testenv`)


dlobato: Hi Matthieu,

I also think that requesting headers from command-api would be better. Since we have the credentials we can then check the status code and conclude if we can actually connect to the eAPI server.

I'll update the code and add the required tests.

David
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851)


codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/aristanetworks/anta/branches/dlobato:check-connection-with-head)

### Merging #851 will **not alter performance**

<sub>Comparing <code>dlobato:check-connection-with-head</code> (0235699) with <code>main</code> (92cbcd3)</sub>



### Summary

`✅ 8` untouched benchmarks





github-actions[bot]: This pull request has conflicts, please resolve those before we can evaluate the pull request.
github-actions[bot]: Conflicts have been resolved. A maintainer will review the pull request shortly.
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851)


github-actions[bot]: This pull request has conflicts, please resolve those before we can evaluate the pull request.
github-actions[bot]: Conflicts have been resolved. A maintainer will review the pull request shortly.
sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851)


github-actions[bot]: This pull request has conflicts, please resolve those before we can evaluate the pull request.
github-actions[bot]: Conflicts have been resolved. A maintainer will review the pull request shortly.
sonarqubecloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851)


sonarqubecloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=851&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=851&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=851&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=851)



Patch:
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index d5d443e2..4562d25c 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -11,13 +11,6 @@ jobs:
     if: github.repository == 'aristanetworks/anta'
     runs-on: ubuntu-latest
     steps:
-      - name: Display workflow run
-        env:
-          WORKFLOW_RUN_JSON: ${{ toJson(github.event.workflow_run) }}
-        run: |
-            echo "--- Workflow Run JSON Start ---"
-            echo "$WORKFLOW_RUN_JSON"
-            echo "--- Workflow Run JSON End ---"
       - uses: actions/checkout@v4
         with:
           ref: ${{ github.event.workflow_run.head_sha }}
diff --git a/anta/device.py b/anta/device.py
index 7c1e6f64..8ca4e3f3 100644
--- a/anta/device.py
+++ b/anta/device.py
@@ -539,29 +539,36 @@ class AsyncEOSDevice(AntaDevice):
         """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()
-        if self.is_online:
-            show_version = AntaCommand(command="show version")
-            await self._collect(show_version)
-            if not show_version.collected:
-                logger.warning("Cannot get hardware information from device %s", self.name)
-            else:
-                self.hw_model = show_version.json_output.get("modelName", None)
-                if self.hw_model is None:
-                    logger.critical("Cannot parse 'show version' returned by device %s", self.name)
-                # in some cases it is possible that 'modelName' comes back empty
-                # 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)
+        try:
+            self.is_online = await self._session.check_api_endpoint()
+        except HTTPError as e:
+            self.is_online = False
+            self.established = False
+            logger.warning("Could not connect to device %s: %s", self.name, e)
+            return
+
+        show_version = AntaCommand(command="show version")
+        await self._collect(show_version)
+        if not show_version.collected:
+            self.established = False
+            logger.warning("Cannot get hardware information from device %s", self.name)
+            return
+
+        self.hw_model = show_version.json_output.get("modelName", None)
+        if self.hw_model is None:
+            self.established = False
+            logger.critical("Cannot parse 'show version' returned by device %s", self.name)
+        # in some cases it is possible that 'modelName' comes back empty
+        elif self.hw_model == "":
+            self.established = False
+            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)
+            self.established = True
 
     async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None:
         """Copy files to and from the device using asyncssh.scp().
diff --git a/asynceapi/device.py b/asynceapi/device.py
index a7702daa..504d072f 100644
--- a/asynceapi/device.py
+++ b/asynceapi/device.py
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload
 # Public Imports
 # -----------------------------------------------------------------------------
 import httpx
+from typing_extensions import deprecated
 
 # -----------------------------------------------------------------------------
 # Private Imports
@@ -51,6 +52,7 @@ class Device(httpx.AsyncClient):
     """
 
     auth = None
+    EAPI_COMMAND_API_URL = "/command-api"
     EAPI_OFMT_OPTIONS = ("json", "text")
     EAPI_DEFAULT_OFMT = "json"
 
@@ -109,6 +111,7 @@ class Device(httpx.AsyncClient):
         super().__init__(**kwargs)
         self.headers["Content-Type"] = "application/json-rpc"
 
+    @deprecated("This method is deprecated, use `Device.check_api_endpoint` method instead. This will be removed in ANTA v2.0.0.", category=DeprecationWarning)
     async def check_connection(self) -> bool:
         """Check the target device to ensure that the eAPI port is open and accepting connections.
 
@@ -122,6 +125,22 @@ class Device(httpx.AsyncClient):
         """
         return await port_check_url(self.base_url)
 
+    async def check_api_endpoint(self) -> bool:
+        """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.
+
+        Returns
+        -------
+        bool
+            True when the device eAPI HTTP endpoint is accessible (2xx status code),
+            otherwise an HTTPStatusError exception is raised.
+        """
+        response = await self.head(self.EAPI_COMMAND_API_URL, timeout=5)
+        response.raise_for_status()
+        return True
+
     # Single command, JSON output, no suppression
     @overload
     async def cli(
@@ -416,7 +435,7 @@ class Device(httpx.AsyncClient):
             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()
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 362d9d0a..90862667 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/cli/conftest.py b/tests/units/cli/conftest.py
index 37646957..d8b308ad 100644
--- a/tests/units/cli/conftest.py
+++ b/tests/units/cli/conftest.py
@@ -123,7 +123,7 @@ def click_runner(capsys: pytest.CaptureFixture[str], anta_env: dict[str, str]) -
 
     # Patch asynceapi methods used by AsyncEOSDevice. See tests/units/test_device.py
     with (
-        patch("asynceapi.device.Device.check_connection", return_value=True),
+        patch("asynceapi.device.Device.check_api_endpoint", return_value=True),
         patch("asynceapi.device.Device.cli", side_effect=cli),
         patch("asyncssh.connect"),
         patch(
diff --git a/tests/units/test_device.py b/tests/units/test_device.py
index e65eeb2a..0ecba555 100644
--- a/tests/units/test_device.py
+++ b/tests/units/test_device.py
@@ -429,29 +429,8 @@ REFRESH_PARAMS: list[ParameterSet] = [
     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",
@@ -653,9 +632,9 @@ class TestAsyncEOSDevice:
     )
     async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[dict[str, Any]], expected: dict[str, Any]) -> None:
         """Test AsyncEOSDevice.refresh()."""
-        with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]):
+        with patch.object(async_device._session, "check_api_endpoint", **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] # asynceapi.Device.check_connection is patched
+            async_device._session.check_api_endpoint.assert_called_once()  # type: ignore[attr-defined] # asynceapi.Device.check_api_endpoint is patched
             if expected["is_online"]:
                 async_device._session.cli.assert_called_once()  # type: ignore[attr-defined] # asynceapi.Device.cli is patched
             assert async_device.is_online == expected["is_online"]