diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index efadf0bc0a4830011a8403d35f36ca9f49645a4d..0c7176deb5dd41b6a1c6d7cf5dcea30a2992a4ba 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -12,6 +12,7 @@ from infoblox_client.exceptions import ( from gso.settings import IPAMParams, load_oss_params logger = getLogger(__name__) +NULL_MAC = "00:00:00:00:00:00" class AllocationError(Exception): @@ -40,11 +41,7 @@ def _setup_connection() -> tuple[connector.Connector, IPAMParams]: def _allocate_network( - conn: connector.Connector, - dns_view: str, - netmask: int, - containers: list[str], - comment: str | None = "", + conn: connector.Connector, dns_view: str, netmask: int, containers: list[str], comment: str | None = "" ) -> ipaddress.IPv4Network | ipaddress.IPv6Network: """Allocate a new network in Infoblox. @@ -160,10 +157,7 @@ def delete_network(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> def allocate_host( - hostname: str, - service_type: str, - cname_aliases: list[str], - comment: str, + hostname: str, service_type: str, cname_aliases: list[str], comment: str ) -> tuple[ipaddress.IPv4Address, ipaddress.IPv6Address]: """Allocate a new host record in Infoblox. @@ -194,7 +188,7 @@ def allocate_host( created_v6 = None for ipv6_range in allocation_networks_v6: v6_alloc = objects.IPAllocation.next_available_ip_from_cidr(dns_view, str(ipv6_range)) - ipv6_object = objects.IP.create(ip=v6_alloc, mac="00:00:00:00:00:00", configure_for_dhcp=False) + ipv6_object = objects.IP.create(ip=v6_alloc, mac=NULL_MAC, configure_for_dhcp=False) try: new_host = objects.HostRecord.create( conn, @@ -216,7 +210,7 @@ def allocate_host( created_v4 = None for ipv4_range in allocation_networks_v4: v4_alloc = objects.IPAllocation.next_available_ip_from_cidr(dns_view, str(ipv4_range)) - ipv4_object = objects.IP.create(ip=v4_alloc, mac="00:00:00:00:00:00", configure_for_dhcp=False) + ipv4_object = objects.IP.create(ip=v4_alloc, mac=NULL_MAC, configure_for_dhcp=False) new_host = objects.HostRecord.search(conn, name=hostname) new_host.ipv4addrs = [ipv4_object] try: @@ -234,9 +228,39 @@ def allocate_host( return created_v4, created_v6 -def find_host_by_ip( - ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address, -) -> objects.HostRecord | None: +def create_host_by_ip( + hostname: str, + ipv4_address: ipaddress.IPv4Address, + ipv6_address: ipaddress.IPv6Address, + service_type: str, + comment: str, +) -> None: + """Create a new host record with a given IPv4 and IPv6 address. + + :param str hostname: The :term:`FQDN` of the new host. + :param IPv4Address ipv4_address: The IPv4 address of the new host. + :param IPv6Address ipv6_address: The IPv6 address of the new host. + :param str service_type: The relevant service type, used to deduce the correct ``dns_view`` in Infoblox. + :param str comment: The comment stored in this Infoblox record, most likely the relevant ``subscription_id`` in + :term:`GSO`. + """ + if not hostname_available(hostname): + msg = f"Cannot allocate new host, FQDN {hostname} already taken." + raise AllocationError(msg) + + conn, oss = _setup_connection() + ipv6_object = objects.IP.create(ip=ipv6_address, mac=NULL_MAC, configure_for_dhcp=False) + ipv4_object = objects.IP.create(ip=ipv4_address, mac=NULL_MAC, configure_for_dhcp=False) + dns_view = getattr(oss, service_type).dns_view + + # This needs to be done in two steps, otherwise only one of the IP addresses is stored. + objects.HostRecord.create(conn, ip=ipv6_object, name=hostname, comment=comment, dns_view=dns_view) + new_host = find_host_by_fqdn(hostname) + new_host.ipv4addrs = [ipv4_object] + new_host.update() + + +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. @@ -249,14 +273,14 @@ def find_host_by_ip( ipv4addr=ip_addr, return_fields=["ipv4addrs", "name", "view", "aliases", "comment"], ) - return objects.HostRecord.search( + return objects.HostRecordV6.search( conn, ipv6addr=ip_addr, return_fields=["ipv6addrs", "name", "view", "aliases", "comment"], ) -def find_host_by_fqdn(fqdn: str) -> objects.HostRecord | None: +def find_host_by_fqdn(fqdn: str) -> objects.HostRecord: """Find a host record by its associated :term:`FQDN`. :param fqdn: The :term:`FQDN` of a host that is searched for. @@ -270,6 +294,18 @@ def find_host_by_fqdn(fqdn: str) -> objects.HostRecord | None: ) +def find_v6_host_by_fqdn(fqdn: str) -> objects.HostRecordV6: + """Find a host record by its associated :term:`FQDN`. + + This specific method will return the IPv6 variant of a record, if it exists. + :param str fqdn: The :term:`FQDN` of a host that is searched for. + """ + conn, _ = _setup_connection() + return objects.HostRecordV6.search( + conn, name=fqdn, return_fields=["ipv6addrs", "name", "view", "aliases", "comment"] + ) + + def delete_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None: """Delete a host from Infoblox. diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 14190a6000ea14d429c27cd0f96e75ddea7f7154..4a754ae0ae540e8d52d93d11231d9278321b10f7 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -16,6 +16,7 @@ from orchestrator.forms import FormPage from orchestrator.forms.validators import Choice, Label, UniqueConstrainedList from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, UUIDstr +from orchestrator.utils.errors import ProcessFailureError from orchestrator.utils.json import json_dumps from orchestrator.workflow import StepList, conditional, done, init, inputstep from orchestrator.workflows.steps import resync, store_process_subscription, unsync @@ -23,11 +24,13 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form from pydantic import validator from pydantic_forms.core import ReadOnlyField from pynetbox.models.dcim import Interfaces +from services.infoblox import DeletionError from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.router import Router +from gso.services import infoblox from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import execute_playbook, pp_interaction from gso.services.subscriptions import get_active_router_subscriptions @@ -167,6 +170,50 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) +@step("Netbox: Reserve new interfaces") +def netbox_reserve_interfaces( + subscription: Iptrunk, new_node: UUIDstr, new_lag_interface: str, new_lag_member_interfaces: list[dict] +) -> State: + """Reserve new interfaces in Netbox, only when the new side's router is a NOKIA router.""" + new_side = Router.from_subscription(new_node).router + nbclient = NetboxClient() + # Create :term:`LAG` interfaces + lag_interface: Interfaces = nbclient.create_interface( + iface_name=new_lag_interface, + interface_type="lag", + device_name=new_side.router_fqdn, + description=str(subscription.subscription_id), + enabled=True, + ) + # Attach physical interfaces to :term:`LAG` + # Reserve interfaces + for interface in new_lag_member_interfaces: + nbclient.attach_interface_to_lag( + device_name=new_side.router_fqdn, + lag_name=lag_interface.name, + iface_name=interface["interface_name"], + description=str(subscription.subscription_id), + ) + nbclient.reserve_interface( + device_name=new_side.router_fqdn, + iface_name=interface["interface_name"], + ) + return {"subscription": subscription} + + +@step("Calculate old side data") +def calculate_old_side_data(subscription: Iptrunk, replace_index: int) -> State: + """Store subscription information of the old side in the state of the workflow for later use.""" + old_subscription = copy.deepcopy(subscription) + old_side_data = { + "iptrunk_side_node": old_subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node, + "iptrunk_side_ae_iface": old_subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_iface, + "iptrunk_side_ae_members": old_subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members, + } + + return {"old_side_data": old_side_data} + + @step("[DRY RUN] Disable configuration on old router") def disable_old_config_dry( subscription: Iptrunk, @@ -488,12 +535,28 @@ def delete_old_config_real( return {"subscription": subscription} -@step("Update IPAM") -def update_ipam(subscription: Iptrunk) -> State: +@step("Update IP records in IPAM") +def update_ipam(subscription: Iptrunk, old_side_data: dict, new_node: Router, new_lag_interface: str) -> State: """Update :term:`IPAM` resources. - TODO: implement + Move the DNS record pointing to the old side of the trunk, to the new side. """ + old_fqdn = f"{old_side_data['iptrunk_side_ae_iface']}.{old_side_data['iptrunk_side_node']['router_fqdn']}" + trunk_v4 = infoblox.find_host_by_fqdn(old_fqdn) + trunk_v6 = infoblox.find_v6_host_by_fqdn(old_fqdn) + + # Out with the old + try: + infoblox.delete_host_by_fqdn(old_fqdn) + except DeletionError as e: + msg = "Failed to delete record from Infoblox." + raise ProcessFailureError(msg) from e + + # And in with the new + new_fqdn = f"{new_lag_interface}.{new_node.router.router_fqdn}" + comment = str(subscription.subscription_id) + infoblox.create_host_by_ip(new_fqdn, trunk_v4.ipv4addr, trunk_v6.ipv6addr, service_type="TRUNK", comment=comment) + return {"subscription": subscription} @@ -507,12 +570,6 @@ def update_subscription_model( ) -> State: """Update the subscription model in the database.""" # Deep copy of subscription data - old_subscription = copy.deepcopy(subscription) - old_side_data = { - "iptrunk_side_node": old_subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node, - "iptrunk_side_ae_iface": old_subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_iface, - "iptrunk_side_ae_members": old_subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members, - } subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node = Router.from_subscription(new_node).router subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_iface = new_lag_interface subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members.clear() @@ -522,40 +579,6 @@ def update_subscription_model( IptrunkInterfaceBlock.new(subscription_id=uuid4(), **member), ) - return {"subscription": subscription, "old_side_data": old_side_data} - - -@step("Netbox: Reserve new interfaces") -def netbox_reserve_interfaces( - subscription: Iptrunk, - new_node: UUIDstr, - new_lag_interface: str, - new_lag_member_interfaces: list[dict], -) -> State: - """Reserve new interfaces in Netbox, only when the new side's router is a NOKIA router.""" - new_side = Router.from_subscription(new_node).router - nbclient = NetboxClient() - # Create :term:`LAG` interfaces - lag_interface: Interfaces = nbclient.create_interface( - iface_name=new_lag_interface, - interface_type="lag", - device_name=new_side.router_fqdn, - description=str(subscription.subscription_id), - enabled=True, - ) - # Attach physical interfaces to :term:`LAG` - # Reserve interfaces - for interface in new_lag_member_interfaces: - nbclient.attach_interface_to_lag( - device_name=new_side.router_fqdn, - lag_name=lag_interface.name, - iface_name=interface["interface_name"], - description=str(subscription.subscription_id), - ) - nbclient.reserve_interface( - device_name=new_side.router_fqdn, - iface_name=interface["interface_name"], - ) return {"subscription": subscription} @@ -628,6 +651,7 @@ def migrate_iptrunk() -> StepList: >> store_process_subscription(Target.MODIFY) >> unsync >> new_side_is_nokia(netbox_reserve_interfaces) + >> calculate_old_side_data >> pp_interaction(set_isis_to_90000) >> pp_interaction(disable_old_config_dry) >> pp_interaction(disable_old_config_real)