From 328ac9be002ee487c4394c49cdc72600ad56867e Mon Sep 17 00:00:00 2001 From: Karel van Klink <karel.vanklink@geant.org> Date: Thu, 8 Aug 2024 15:40:29 +0200 Subject: [PATCH] Search for all IPs in Infoblox in allocated networks when creating a trunk --- gso/services/infoblox.py | 4 +- gso/workflows/iptrunk/create_iptrunk.py | 53 +++++++-- test/workflows/iptrunk/test_create_iptrunk.py | 106 +++++++++++++++++- 3 files changed, 147 insertions(+), 16 deletions(-) diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index d340ce97..22e56ab5 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -268,11 +268,11 @@ def create_host_by_ip( new_host.update() -def find_host_by_ip(ip_addr: IPv4AddressType | ipaddress.IPv6Address) -> objects.HostRecord | None: +def find_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> objects.HostRecord | None: """Find a host record in Infoblox by its associated IP address. :param ip_addr: The IP address of a host that is searched for. - :type ip_addr: IPv4AddressType | ipaddress.IPv6Address + :type ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address """ conn, _ = _setup_connection() if ip_addr.version == 4: # noqa: PLR2004, the 4 in IPv4 is well-known and not a "magic value." diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index a0d023fd..6a542793 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -1,6 +1,7 @@ """A creation workflow that deploys a new IP trunk service.""" import json +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network from typing import Annotated from uuid import uuid4 @@ -212,22 +213,48 @@ def create_subscription(product: UUIDstr, partner: str) -> State: @step("Get information from IPAM") def get_info_from_ipam(subscription: IptrunkInactive) -> State: """Allocate IP resources in :term:`IPAM`.""" - subscription.iptrunk.iptrunk_ipv4_network = infoblox.allocate_v4_network( + new_ipv4_network = infoblox.allocate_v4_network( "TRUNK", subscription.iptrunk.iptrunk_description, ) - subscription.iptrunk.iptrunk_ipv6_network = infoblox.allocate_v6_network( + new_ipv6_network = infoblox.allocate_v6_network( "TRUNK", subscription.iptrunk.iptrunk_description, ) + subscription.iptrunk.iptrunk_ipv4_network = new_ipv4_network + subscription.iptrunk.iptrunk_ipv6_network = new_ipv6_network - return {"subscription": subscription} + return { + "subscription": subscription, + "new_ipv4_network": str(new_ipv4_network), + "new_ipv6_network": str(new_ipv6_network), + } + + +@step("Check for existing DNS records in the assigned IPv4 network") +def dig_all_hosts_v4(new_ipv4_network: str) -> None: + """Check if any hosts have already been assigned inside the IPv4 network in Netbox.""" + registered_hosts = [host for host in IPv4Network(new_ipv4_network) if infoblox.find_host_by_ip(IPv4Address(host))] + + if registered_hosts: + msg = "One or more hosts in the assigned IPv4 network are already registered, please investigate." + raise ProcessFailureError(msg, details=registered_hosts) + + +@step("Check for existing DNS records in the assigned IPv6 network") +def dig_all_hosts_v6(new_ipv6_network: str) -> None: + """Check if any hosts have already been assigned inside the IPv6 network in Netbox.""" + registered_hosts = [host for host in IPv6Network(new_ipv6_network) if infoblox.find_host_by_ip(IPv6Address(host))] + + if registered_hosts: + msg = "One or more hosts in the assigned IPv6 network are already registered, please investigate." + raise ProcessFailureError(msg, details=registered_hosts) @step("Ping all hosts in the assigned IPv4 network") -def ping_all_hosts_v4(subscription: IptrunkInactive) -> None: +def ping_all_hosts_v4(new_ipv4_network: str) -> None: """Ping all hosts in the IPv4 network to verify they're not in use.""" - unavailable_hosts = [host for host in subscription.iptrunk.iptrunk_ipv4_network if ping(host, timeout=1)] + unavailable_hosts = [host for host in IPv4Network(new_ipv4_network) if ping(str(host), timeout=1)] if unavailable_hosts: msg = "One or more hosts in the assigned IPv4 network are responding to ping, please investigate." @@ -235,14 +262,16 @@ def ping_all_hosts_v4(subscription: IptrunkInactive) -> None: @step("Ping all hosts in the assigned IPv6 network") -def ping_all_hosts_v6(subscription: IptrunkInactive) -> None: +def ping_all_hosts_v6(new_ipv6_network: str) -> State: """Ping all hosts in the IPv6 network to verify they're not in use.""" - unavailable_hosts = [host for host in subscription.iptrunk.iptrunk_ipv6_network if ping(host, timeout=1)] + unavailable_hosts = [host for host in IPv6Network(new_ipv6_network) if ping(str(host), timeout=1)] if unavailable_hosts: msg = "One or more hosts in the assigned IPv6 network are responding to ping, please investigate." raise ProcessFailureError(msg, details=unavailable_hosts) + return {"__remove_keys": ["new_ipv4_network", "new_ipv6_network"]} + @step("Initialize subscription") def initialize_subscription( @@ -550,7 +579,15 @@ def create_iptrunk() -> StepList: side_b_is_nokia = conditional(lambda state: get_router_vendor(state["side_b_node_id"]) == Vendor.NOKIA) assign_ip_networks = step_group( - name="Assign IP networks", steps=begin >> get_info_from_ipam >> ping_all_hosts_v4 >> ping_all_hosts_v6 + name="Assign IP networks", + steps=( + begin + >> get_info_from_ipam + >> dig_all_hosts_v4 + >> dig_all_hosts_v6 + >> ping_all_hosts_v4 + >> ping_all_hosts_v6 + ), ) return ( diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index de15a712..117444b7 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -2,6 +2,7 @@ from os import PathLike from unittest.mock import patch import pytest +from infoblox_client.objects import HostRecord from gso.products import Iptrunk, ProductName from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity @@ -11,6 +12,7 @@ from test import USER_CONFIRM_EMPTY_FORM from test.services.conftest import MockedNetboxClient, MockedSharePointClient from test.workflows import ( assert_complete, + assert_failed, assert_lso_interaction_failure, assert_lso_interaction_success, assert_suspended, @@ -102,9 +104,13 @@ def input_form_wizard_data(request, juniper_router_subscription_factory, nokia_r @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v6_network") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v4_network") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.create_host_by_ip") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.find_host_by_ip") +@patch("gso.workflows.iptrunk.create_iptrunk.ping") @patch("gso.workflows.iptrunk.create_iptrunk.SharePointClient") def test_successful_iptrunk_creation_with_standard_lso_result( mock_sharepoint_client, + mock_ping, + mock_find_host_by_ip, mock_create_host, mock_allocate_v4_network, mock_allocate_v6_network, @@ -117,8 +123,10 @@ def test_successful_iptrunk_creation_with_standard_lso_result( test_client, ): mock_create_host.return_value = None - mock_allocate_v4_network.return_value = faker.ipv4_network(max_subnet=31) - mock_allocate_v6_network.return_value = faker.ipv6_network(max_subnet=126) + mock_allocate_v4_network.return_value = faker.ipv4_network(min_subnet=31, max_subnet=31) + mock_allocate_v6_network.return_value = faker.ipv6_network(min_subnet=126, max_subnet=126) + mock_find_host_by_ip.return_value = None + mock_ping.return_value = False mock_sharepoint_client.return_value = MockedSharePointClient product_id = get_product_id_by_name(ProductName.IP_TRUNK) @@ -147,13 +155,20 @@ def test_successful_iptrunk_creation_with_standard_lso_result( ) assert mock_execute_playbook.call_count == 6 + # We search for 6 hosts in total, 2 in a /31 and 4 in a /126 + assert mock_find_host_by_ip.call_count == 6 + assert mock_ping.call_count == 6 @pytest.mark.workflow() @patch("gso.workflows.iptrunk.create_iptrunk.execute_playbook") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v6_network") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v4_network") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.find_host_by_ip") +@patch("gso.workflows.iptrunk.create_iptrunk.ping") def test_iptrunk_creation_fails_when_lso_return_code_is_one( + mock_ping, + mock_find_host_by_ip, mock_allocate_v4_network, mock_allocate_v6_network, mock_execute_playbook, @@ -163,8 +178,10 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( _netbox_client_mock, # noqa: PT019 data_config_filename: PathLike, ): - mock_allocate_v4_network.return_value = faker.ipv4_network(max_subnet=31) - mock_allocate_v6_network.return_value = faker.ipv6_network(max_subnet=126) + mock_allocate_v4_network.return_value = faker.ipv4_network(min_subnet=31, max_subnet=31) + mock_allocate_v6_network.return_value = faker.ipv6_network(min_subnet=126, max_subnet=126) + mock_find_host_by_ip.return_value = None + mock_ping.return_value = False product_id = get_product_id_by_name(ProductName.IP_TRUNK) initial_site_data = [{"product": product_id}, *input_form_wizard_data] @@ -175,6 +192,8 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( assert_lso_interaction_failure(result, process_stat, step_log) assert mock_execute_playbook.call_count == 2 + assert mock_find_host_by_ip.call_count == 6 + assert mock_ping.call_count == 6 @pytest.mark.parametrize("input_form_wizard_data", [Vendor.JUNIPER], indirect=True) @@ -183,9 +202,13 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v6_network") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v4_network") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.create_host_by_ip") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.find_host_by_ip") +@patch("gso.workflows.iptrunk.create_iptrunk.ping") @patch("gso.workflows.iptrunk.create_iptrunk.SharePointClient") def test_successful_iptrunk_creation_with_juniper_interface_names( mock_sharepoint_client, + mock_ping, + mock_find_host_by_ip, mock_create_host, mock_allocate_v4_network, mock_allocate_v6_network, @@ -198,8 +221,11 @@ def test_successful_iptrunk_creation_with_juniper_interface_names( test_client, ): mock_create_host.return_value = None - mock_allocate_v4_network.return_value = faker.ipv4_network(max_subnet=31) - mock_allocate_v6_network.return_value = faker.ipv6_network(max_subnet=126) + mock_allocate_v4_network.return_value = faker.ipv4_network(min_subnet=31, max_subnet=31) + mock_allocate_v6_network.return_value = faker.ipv6_network(min_subnet=126, max_subnet=126) + mock_find_host_by_ip.return_value = None + mock_ping.return_value = False + mock_sharepoint_client.return_value = MockedSharePointClient product_id = get_product_id_by_name(ProductName.IP_TRUNK) initial_site_data = [{"product": product_id}, *input_form_wizard_data] @@ -213,3 +239,71 @@ def test_successful_iptrunk_creation_with_juniper_interface_names( assert_complete(result) assert mock_execute_playbook.call_count == 6 + assert mock_find_host_by_ip.call_count == 6 + assert mock_ping.call_count == 6 + + +@pytest.mark.workflow() +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v6_network") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v4_network") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.find_host_by_ip") +def test_iptrunk_creation_with_taken_dns_record( + mock_find_host_by_ip, + mock_allocate_v4_network, + mock_allocate_v6_network, + input_form_wizard_data, + faker, + _netbox_client_mock, # noqa: PT019 +): + mock_allocate_v4_network.return_value = faker.ipv4_network(min_subnet=31, max_subnet=31) + mock_allocate_v6_network.return_value = faker.ipv6_network(min_subnet=126, max_subnet=126) + mock_find_host_by_ip.return_value = HostRecord(connector=None, hostname="fake.internal") + + product_id = get_product_id_by_name(ProductName.IP_TRUNK) + initial_site_data = [{"product": product_id}, *input_form_wizard_data] + result, _, _ = run_workflow("create_iptrunk", initial_site_data) + + assert_failed(result) + + state = extract_state(result) + assert ( + state["error"] == "One or more hosts in the assigned IPv4 network are already registered, please investigate." + ) + + # We search for 2 hosts in a /31 and then fail the workflow + assert mock_find_host_by_ip.call_count == 2 + + +@pytest.mark.workflow() +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v6_network") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v4_network") +@patch("gso.workflows.iptrunk.create_iptrunk.infoblox.find_host_by_ip") +@patch("gso.workflows.iptrunk.create_iptrunk.ping") +def test_iptrunk_creation_with_taken_ip_address( + mock_ping, + mock_find_host_by_ip, + mock_allocate_v4_network, + mock_allocate_v6_network, + input_form_wizard_data, + faker, + _netbox_client_mock, # noqa: PT019 +): + mock_allocate_v4_network.return_value = faker.ipv4_network(min_subnet=31, max_subnet=31) + mock_allocate_v6_network.return_value = faker.ipv6_network(min_subnet=126, max_subnet=126) + mock_find_host_by_ip.return_value = None + mock_ping.return_value = True + + product_id = get_product_id_by_name(ProductName.IP_TRUNK) + initial_site_data = [{"product": product_id}, *input_form_wizard_data] + result, _, _ = run_workflow("create_iptrunk", initial_site_data) + + assert_failed(result) + + state = extract_state(result) + assert ( + state["error"] == "One or more hosts in the assigned IPv4 network are responding to ping, please investigate." + ) + + assert mock_find_host_by_ip.call_count == 6 + # We ping 2 hosts in a /31 and then fail the workflow + assert mock_ping.call_count == 2 -- GitLab