diff --git a/Changelog.md b/Changelog.md index e99ea7d2beacb3e19f12b4cec8909820722ef9be..e0fb0c1148fefe55ce431d5331ff14627e1a73b7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,8 @@ # Changelog -All notable changes to this project will be documented in this file. +## [2.11] - 2024-08-19 +- (fix) Make LibreNMS retry when a request times out +- (fix) Adjust the mechanics of the minimum amount of links when creating an IP trunk ## [2.10] - 2024-08-06 - Update map API endpoint diff --git a/gso/services/librenms_client.py b/gso/services/librenms_client.py index c700c92fc349afdb6521c65d0ef6720548f78597..2e04a866e3f85b539f22031b3f878b2149823d0f 100644 --- a/gso/services/librenms_client.py +++ b/gso/services/librenms_client.py @@ -3,10 +3,11 @@ import logging from http import HTTPStatus from importlib import metadata -from typing import Any +from typing import Any, Literal import requests -from requests import HTTPError +from requests import HTTPError, Response +from requests.adapters import HTTPAdapter from gso.settings import load_oss_params from gso.utils.helpers import SNMPVersion @@ -25,12 +26,24 @@ class LibreNMSClient: self.base_url = config.LIBRENMS.base_url self.snmp_config = config.SNMP - self.headers = { + self.session = requests.Session() + self.session.mount("https://", HTTPAdapter(max_retries=5)) + self.session.headers.update({ "User-Agent": f"geant-service-orchestrator/{metadata.version("geant-service-orchestrator")}", "Accept": "application/json", "Content-Type": "application/json", "X-Auth-Token": token, - } + }) + + def _send_request( + self, method: Literal["GET", "POST", "PUT", "DELETE"], endpoint: str, data: dict[str, Any] | None = None + ) -> Response: + url = self.base_url + endpoint + logger.debug("LibreNMS - Sending request", extra={"method": method, "endpoint": url, "form_data": data}) + result = self.session.request(method, url, json=data, timeout=(0.5, 75)) + logger.debug("LibreNMS - Received response", extra=result.__dict__) + + return result def get_device(self, fqdn: str) -> dict[str, Any]: """Get an existing device from LibreNMS. @@ -39,7 +52,7 @@ class LibreNMSClient: :return dict[str, Any]: A :term:`JSON` formatted list of devices that match the queried :term:`FQDN`. :raises HTTPError: Raises an HTTP error 404 when the device is not found """ - response = requests.get(f"{self.base_url}/devices/{fqdn}", headers=self.headers, timeout=(0.5, 75)) + response = self._send_request("GET", f"/devices/{fqdn}") response.raise_for_status() return response.json() @@ -74,7 +87,7 @@ class LibreNMSClient: } device_data.update(getattr(self.snmp_config, snmp_version)) - device = requests.post(f"{self.base_url}/devices", headers=self.headers, json=device_data, timeout=(0.5, 75)) + device = self._send_request("POST", "/devices", device_data) device.raise_for_status() return device.json() @@ -86,7 +99,7 @@ class LibreNMSClient: :return dict[str, Any]: A JSON representation of the device that got removed. :raises HTTPError: Raises an exception if the request did not succeed. """ - device = requests.delete(f"{self.base_url}/devices/{fqdn}", headers=self.headers, timeout=(0.5, 75)) + device = self._send_request("DELETE", f"/devices/{fqdn}") device.raise_for_status() return device.json() diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index fdf851e8495c697e95ad45747d07269f29e7a2db..cca681a4be8d56c0772aaae40f5929f516c3dbc5 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -12,7 +12,7 @@ from pydantic_core.core_schema import ValidationInfo from pydantic_forms.validators import Choice from gso import settings -from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock +from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, PhysicalPortCapacity from gso.products.product_blocks.router import RouterRole from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate, SiteTier from gso.products.product_types.router import Router @@ -332,3 +332,18 @@ def generate_inventory_for_active_routers( } } } + + +def calculate_recommended_minimum_links(iptrunk_number_of_members: int, iptrunk_speed: PhysicalPortCapacity) -> int: + """Calculate the recommended minimum number of links for an IP trunk based on the number of members and speed. + + If the IP trunk speed is 400G, the recommended minimum number of links is the number of members minus 1. + Otherwise, the recommended minimum number of links is the number of members. + + :param int iptrunk_number_of_members: The number of members in the IP trunk. + :param PhysicalPortCapacity iptrunk_speed: The speed of the IP trunk. + :return: The recommended minimum number of links for the IP trunk. + """ + if iptrunk_speed == PhysicalPortCapacity.FOUR_HUNDRED_GIGABIT_PER_SECOND: + return iptrunk_number_of_members - 1 + return iptrunk_number_of_members diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index 95703094cf67299b6b235e914de4959ff5426832..373dfc03b63e378b2889c62aca3496011121f909 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -35,6 +35,7 @@ from gso.utils.helpers import ( LAGMember, available_interfaces_choices, available_lags_choices, + calculate_recommended_minimum_links, get_router_vendor, validate_interface_name_list, validate_iptrunk_unique_interface, @@ -70,12 +71,13 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: return validate_tt_number(tt_number) initial_user_input = yield CreateIptrunkForm + recommended_minimum_links = calculate_recommended_minimum_links( + initial_user_input.iptrunk_number_of_members, initial_user_input.iptrunk_speed + ) class VerifyMinimumLinksForm(FormPage): - info_label: Label = ( - f"This is the calculated minimum-links for this LAG: " f"{initial_user_input.iptrunk_number_of_members - 1}" - ) - iptrunk_minimum_links: int = initial_user_input.iptrunk_number_of_members - 1 + info_label: Label = f"This is the calculated minimum-links for this LAG: {recommended_minimum_links}" + iptrunk_minimum_links: int = recommended_minimum_links info_label2: Label = "Please confirm or modify." verify_minimum_links = yield VerifyMinimumLinksForm diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 7ed4c0bc110c827aa5e7880f5d723d8a8b28e549..f5d17d7752503d8fdb8979d0eefadd7818af4936 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -28,6 +28,7 @@ from gso.utils.helpers import ( LAGMember, available_interfaces_choices, available_interfaces_choices_including_current_members, + calculate_recommended_minimum_links, get_router_vendor, validate_interface_name_list, validate_iptrunk_unique_interface, @@ -93,7 +94,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: "You will need to add the new AE members in the next steps." ) iptrunk_speed: PhysicalPortCapacity = subscription.iptrunk.iptrunk_speed - iptrunk_number_of_members: int = subscription.iptrunk.iptrunk_minimum_links + 1 + iptrunk_number_of_members: int = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) iptrunk_isis_metric: ReadOnlyField(subscription.iptrunk.iptrunk_isis_metric, default_type=int) # type: ignore[valid-type] iptrunk_ipv4_network: ReadOnlyField( # type: ignore[valid-type] str(subscription.iptrunk.iptrunk_ipv4_network), default_type=IPv4AddressType @@ -108,12 +109,15 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: initial_user_input = yield ModifyIptrunkForm + recommended_minimum_links = calculate_recommended_minimum_links( + initial_user_input.iptrunk_number_of_members, initial_user_input.iptrunk_speed + ) + class VerifyMinimumLinksForm(FormPage): - info_label: Label = ( - f"This is the calculated minimum-links for this LAG: " f"{initial_user_input.iptrunk_number_of_members - 1}" - ) - iptrunk_minimum_links: int = initial_user_input.iptrunk_number_of_members - 1 - info_label2: Label = "Please confirm or modify." + info_label: Label = f"Current value of minimum-links : {subscription.iptrunk.iptrunk_minimum_links}" + info_label1: Label = f"Recommended minimum-links for this LAG: {recommended_minimum_links}" + iptrunk_minimum_links: int = recommended_minimum_links + info_label2: Label = "Please review the recommended value and adjust if necessary." verify_minimum_links = yield VerifyMinimumLinksForm ae_members_side_a = initialize_ae_members(subscription, initial_user_input.model_dump(), 0) diff --git a/setup.py b/setup.py index eedcacefcf3e943a30fc8a84ab84e5247be5c083..c61a2ba1d1da316081a80efe62328a2888138868 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="2.10", + version="2.11", author="GÉANT Orchestration and Automation Team", author_email="goat@geant.org", description="GÉANT Service Orchestrator", diff --git a/test/services/test_librenms_client.py b/test/services/test_librenms_client.py index 7668ddea0ad41a31b2fa92e5462eb2931da3207b..e28eaef74742d59d8b443ba0fad1d323f5b26254 100644 --- a/test/services/test_librenms_client.py +++ b/test/services/test_librenms_client.py @@ -10,7 +10,7 @@ from gso.utils.helpers import SNMPVersion @pytest.fixture() def mock_get_device_success(faker): - with patch("gso.services.librenms_client.requests.get") as mock_get_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_get_device: mock_get_device().status_code = HTTPStatus.OK mock_get_device().json.return_value = { "status": "ok", @@ -81,14 +81,14 @@ def mock_get_device_success(faker): @pytest.fixture() def mock_get_device_not_found(): - with patch("gso.services.librenms_client.requests.get") as mock_get_not_found: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_get_not_found: mock_get_not_found().status_code = HTTPStatus.NOT_FOUND mock_get_not_found().json.return_value = { "status": "error", "message": "Device non-existent-url does not exist", } mock_get_not_found().raise_for_status.side_effect = HTTPError( - "404 Client Error: Not Found for url: http://librenms/devices/non-existent-url", + "404 Client Error: Not Found for url: https://librenms/devices/non-existent-url", response=mock_get_not_found(), ) @@ -97,7 +97,7 @@ def mock_get_device_not_found(): @pytest.fixture() def mock_get_device_misconfigured(faker): - with patch("gso.services.librenms_client.requests.get") as mock_get_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_get_device: mock_get_device().status_code = HTTPStatus.OK mock_get_device().json.return_value = { "status": "ok", @@ -169,7 +169,7 @@ def mock_get_device_misconfigured(faker): @pytest.fixture() def mock_get_device_unauthenticated(): with ( - patch("gso.services.librenms_client.requests.get") as mock_get_unauthorized, + patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_get_unauthorized, patch( "gso.services.librenms_client.LibreNMSClient.get_device", ) as mock_get_device, @@ -177,7 +177,7 @@ def mock_get_device_unauthenticated(): mock_get_unauthorized().status_code = HTTPStatus.UNAUTHORIZED mock_get_unauthorized().json.return_value = {"message": "Unauthenticated."} mock_get_device.side_effect = HTTPError( - "401 Client Error: Unauthorized for url: http://librenms/devices/naughty-url", + "401 Client Error: Unauthorized for url: https://librenms/devices/naughty-url", response=mock_get_unauthorized(), ) @@ -186,7 +186,7 @@ def mock_get_device_unauthenticated(): @pytest.fixture() def mock_add_device_success(): - with patch("gso.services.librenms_client.requests.post") as mock_post_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_post_device: mock_post_device().status_code = HTTPStatus.OK mock_post_device().json.return_value = { "status": "ok", @@ -222,14 +222,14 @@ def mock_add_device_success(): @pytest.fixture() def mock_add_device_bad_url(): - with patch("gso.services.librenms_client.requests.post") as mock_post_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_post_device: mock_post_device().status_code = HTTPStatus.INTERNAL_SERVER_ERROR mock_post_device().json.return_value = { "status": "error", "message": "Could not ping non-existent-url (Hostname did not resolve to IP)", } mock_post_device().raise_for_status.side_effect = HTTPError( - "500 Server Error: Internal server error for url: http://librenms/devices", + "500 Server Error: Internal server error for url: https://librenms/devices", response=mock_post_device(), ) @@ -238,14 +238,14 @@ def mock_add_device_bad_url(): @pytest.fixture() def mock_add_device_unreachable(): - with patch("gso.services.librenms_client.requests.post") as mock_post_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_post_device: mock_post_device().status_code = HTTPStatus.INTERNAL_SERVER_ERROR mock_post_device().json.return_value = { "status": "error", "message": "Could not connect to non-existent-url, please check the snmp details and snmp reachability", } mock_post_device().raise_for_status.side_effect = HTTPError( - "500 Server Error: Internal server error for url: http://librenms/devices", + "500 Server Error: Internal server error for url: https://librenms/devices", response=mock_post_device(), ) @@ -254,7 +254,7 @@ def mock_add_device_unreachable(): @pytest.fixture() def mock_remove_device_success(faker): - with patch("gso.services.librenms_client.requests.delete") as mock_remove_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_remove_device: mock_remove_device().status_code = HTTPStatus.OK mock_remove_device().json.return_value = { "status": "ok", @@ -326,11 +326,11 @@ def mock_remove_device_success(faker): @pytest.fixture() def mock_remove_device_non_existent(faker): - with patch("gso.services.librenms_client.requests.delete") as mock_remove_device: + with patch("gso.services.librenms_client.LibreNMSClient._send_request") as mock_remove_device: mock_remove_device().status_code = HTTPStatus.NOT_FOUND mock_remove_device().json.return_value = {"status": "error", "message": "Device non-existent-url not found"} mock_remove_device().raise_for_status.side_effect = HTTPError( - "404 Client Error: Not Found for url: http://librenms/devices/non-existent-url", + "404 Client Error: Not Found for url: https://librenms/devices/non-existent-url", response=mock_remove_device(), ) @@ -353,7 +353,7 @@ def test_get_device_not_found(mock_get_device_not_found): assert e.value.response.status_code == HTTPStatus.NOT_FOUND assert e.value.response.json() == {"status": "error", "message": "Device non-existent-url does not exist"} - assert e.value.args[0] == "404 Client Error: Not Found for url: http://librenms/devices/non-existent-url" + assert e.value.args[0] == "404 Client Error: Not Found for url: https://librenms/devices/non-existent-url" def test_device_exists_true(mock_get_device_success): @@ -376,7 +376,7 @@ def test_device_exists_bad_request(mock_get_device_unauthenticated): assert e.value.response.status_code == HTTPStatus.UNAUTHORIZED assert e.value.response.json() == {"message": "Unauthenticated."} - assert e.value.args[0] == "401 Client Error: Unauthorized for url: http://librenms/devices/naughty-url" + assert e.value.args[0] == "401 Client Error: Unauthorized for url: https://librenms/devices/naughty-url" def test_add_device_success(mock_add_device_success): @@ -401,7 +401,7 @@ def test_add_device_bad_fqdn(mock_add_device_bad_url): "status": "error", "message": "Could not ping non-existent-url (Hostname did not resolve to IP)", } - assert e.value.args[0] == "500 Server Error: Internal server error for url: http://librenms/devices" + assert e.value.args[0] == "500 Server Error: Internal server error for url: https://librenms/devices" def test_add_device_no_ping(mock_add_device_unreachable): @@ -416,7 +416,7 @@ def test_add_device_no_ping(mock_add_device_unreachable): "status": "error", "message": "Could not connect to non-existent-url, please check the snmp details and snmp reachability", } - assert e.value.args[0] == "500 Server Error: Internal server error for url: http://librenms/devices" + assert e.value.args[0] == "500 Server Error: Internal server error for url: https://librenms/devices" def test_remove_device_success(mock_remove_device_success): @@ -435,7 +435,7 @@ def test_remove_non_existent_device(mock_remove_device_non_existent): assert e.value.response.status_code == HTTPStatus.NOT_FOUND assert e.value.response.json() == {"status": "error", "message": "Device non-existent-url not found"} - assert e.value.args[0] == "404 Client Error: Not Found for url: http://librenms/devices/non-existent-url" + assert e.value.args[0] == "404 Client Error: Not Found for url: https://librenms/devices/non-existent-url" def test_validate_device_success(mock_get_device_success):