diff --git a/gso/services/netbox_client.py b/gso/services/netbox_client.py index f13fe47741111c9be68c31abe5e6005349499ce4..d5e31fdf14a430dd2049afdece2ba29ef4d93a69 100644 --- a/gso/services/netbox_client.py +++ b/gso/services/netbox_client.py @@ -67,7 +67,9 @@ class NetBoxClient: self.netbox.dcim.interfaces.filter(device_id=device.id, enabled=False, mark_connected=False, speed=speed) ) - def create_interface(self, iface_name: str, type: str, speed: str, device_name: str) -> Interfaces: + def create_interface( + self, iface_name: str, type: str, device_name: str, description: str | None = None, enabled: bool = False + ) -> Interfaces: """Create new interface on a device, where device is defined by name. The type parameter can be 1000base-t, 10gbase-t, lag, etc. @@ -76,7 +78,12 @@ class NetBoxClient: """ device = self.get_device_by_name(device_name) return self.netbox.dcim.interfaces.create( - name=iface_name, type=type, speed=speed, enabled=False, mark_connected=False, device=device.id + name=iface_name, + type=type, + enabled=enabled, + mark_connected=False, + device=device.id, + description=description, ) def create_device_type(self, manufacturer: str, model: str, slug: str) -> DeviceTypes: @@ -110,7 +117,7 @@ class NetBoxClient: device_role = self.netbox.dcim.device_roles.get(name="router") # Get site id - device_site = self.netbox.dcim.sites.get(name="Amsterdam") + device_site = self.netbox.dcim.sites.get(name="GEANT") # Create new device device = self.netbox.dcim.devices.create( @@ -118,40 +125,59 @@ class NetBoxClient: ) module_bays = list(self.netbox.dcim.module_bays.filter(device_id=device.id)) card_type = self.netbox.dcim.module_types.get(model=tier_info.module_type) + # TODo: Use the module_bays_slots to create the modules for module_bay in module_bays: self.netbox.dcim.modules.create( device=device.id, module_bay=module_bay.id, module_type=card_type.id, status="active", + enabled=False, comments="Installed via pynetbox", ) + for interface in self.netbox.dcim.interfaces.filter(device_id=device.id): + speed = None + type_parts = interface.type.value.split("-") + if "gbase" in type_parts[0]: + # Extract the numeric part and convert to bits per second + speed = int("".join(filter(str.isdigit, type_parts[0]))) * 1000000 + interface.speed = speed + interface.enabled = False + interface.save() + return device def delete_device(self, router_name: str) -> None: self.netbox.dcim.devices.get(name=router_name).delete() return - def attach_interface_to_lag(self, device_name: str, lag_name: str, iface_name: str) -> Interfaces: - """Assign a given interface to a lag. + def attach_interface_to_lag( + self, device_name: str, lag_name: str, iface_name: str, description: str | None = None + ) -> Interfaces: + """Assign a given interface to a LAG. - Returns the lag object with the assignend interfaces + Returns the interface object after assignment. """ # Get device id device = self.get_device_by_name(device_name) - # Now get interface for device + # Get interface for device iface = self.netbox.dcim.interfaces.get(name=iface_name, device_id=device.id) - # Get lag + # Get LAG lag = self.netbox.dcim.interfaces.get(name=lag_name, device_id=device.id) - # Assign interface to lag + # Assign interface to LAG iface.lag = lag.id - # Update interface - return self.netbox.dcim.interfaces.update(iface) + # Set description if provided + if description: + iface.description = description + + iface.save() + + return iface def reserve_interface(self, device_name: str, iface_name: str) -> Interfaces: """Reserve an interface by enabling it.""" @@ -210,3 +236,10 @@ class NetBoxClient: # Return available lags not assigned to the device return [lag for lag in all_feasible_lags if lag not in lag_interface_names] + + def get_available_interfaces(self, router_id: UUID, speed: str) -> Interfaces: + router = Router.from_subscription(router_id).router.router_fqdn + device = self.get_device_by_name(router) + return self.netbox.dcim.interfaces.filter( + device=device.name, enabled=False, mark_connected=False, speed=int(speed.split("G")[0]) * 1000000 + ) diff --git a/gso/utils/device_info.py b/gso/utils/device_info.py index 1c193557f8c21515103b210517f4c1e5ab58a597..219e4950a204062b012499e98400c31239e0c296 100644 --- a/gso/utils/device_info.py +++ b/gso/utils/device_info.py @@ -12,16 +12,16 @@ class ModuleInfo(BaseModel): class TierInfo: def __init__(self) -> None: self.Tier1 = ModuleInfo( - device_type="7750-SR7s", + device_type="7750 SR-7s", module_bays_slots=[1, 2], - module_type="XCM2s-XMA2s-36p-800g", + module_type="XMA2-s-36p-400g", breakout_interfaces_per_slot=[36, 35, 34, 33], total_10g_interfaces=80, ) self.Tier2 = ModuleInfo( device_type="7750-SR7s", module_bays_slots=[1, 2], - module_type="XCM2s-XMA2s-36p-400g", + module_type="XMA2-s-36p-400g", breakout_interfaces_per_slot=[36, 35, 34, 33], total_10g_interfaces=60, ) @@ -30,4 +30,5 @@ class TierInfo: return getattr(self, name) -FEASIBLE_IP_TRUNK_LAG_RANGE = range(1, 10) +# The range includes values from 1 to 10 (11 is not included) +FEASIBLE_IP_TRUNK_LAG_RANGE = range(1, 11) diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index ae2c8cf9301e1e634d3695c8b5078c834705970d..b96b9d8818d3636c58d42148cc58c59f2c2a7943 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -1,18 +1,29 @@ +from typing import NoReturn + from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice, UniqueConstrainedList +from orchestrator.forms.validators import Choice, ChoiceList, UniqueConstrainedList from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form +from pydantic import validator +from pynetbox.models.dcim import Interfaces from gso.products.product_blocks import PhyPortCapacity from gso.products.product_blocks.iptrunk import IptrunkType +from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning from gso.products.product_types.router import Router from gso.services import infoblox, provisioning_proxy, subscriptions +from gso.services.netbox_client import NetBoxClient from gso.services.provisioning_proxy import pp_interaction -from gso.workflows.utils import customer_selector +from gso.workflows.utils import ( + available_interfaces_choices, + available_lags_choices, + customer_selector, + get_router_vendor, +) def initial_input_form_generator(product_name: str) -> FormGenerator: @@ -39,28 +50,83 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: initial_user_input = yield CreateIptrunkForm - class AeMembersListA(UniqueConstrainedList[str]): + router_enum_a = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore + + class SelectRouterSideA(FormPage): + class Config: + title = "Select a router for side A of the trunk." + + iptrunk_sideA_node_id: router_enum_a # type: ignore[valid-type] + + @validator("iptrunk_sideA_node_id", allow_reuse=True) + def validate_device_exists_in_netbox(cls, iptrunk_sideA_node_id: UUIDstr) -> str | NoReturn: + router = Router.from_subscription(iptrunk_sideA_node_id).router + if router.router_vendor == RouterVendor.NOKIA: + device = NetBoxClient().get_device_by_name(router.router_fqdn) + if not device: + raise ValueError("The selected router does not exist in Netbox.") + return iptrunk_sideA_node_id + + user_input_router_side_a = yield SelectRouterSideA + router_a = user_input_router_side_a.iptrunk_sideA_node_id.name + side_a_ae_iface = available_lags_choices(router_a) or str + + class AeMembersListA(ChoiceList): min_items = initial_user_input.iptrunk_minimum_links + item_type = available_interfaces_choices(router_a, initial_user_input.iptrunk_speed) # type: ignore + unique_items = True - RouterEnumA = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] + class JuniperAeMembers(UniqueConstrainedList[str]): + min_items = initial_user_input.iptrunk_minimum_links + unique_items = True + + ae_members_side_a = AeMembersListA if get_router_vendor(router_a) == RouterVendor.NOKIA else JuniperAeMembers + + class AeMembersDescriptionListA(UniqueConstrainedList[str]): + min_items = initial_user_input.iptrunk_minimum_links class CreateIptrunkSideAForm(FormPage): class Config: title = "Provide subscription details for side A of the trunk." - iptrunk_sideA_node_id: RouterEnumA # type: ignore[valid-type] - iptrunk_sideA_ae_iface: str + iptrunk_sideA_ae_iface: side_a_ae_iface # type: ignore[valid-type] iptrunk_sideA_ae_geant_a_sid: str - iptrunk_sideA_ae_members: AeMembersListA - iptrunk_sideA_ae_members_descriptions: AeMembersListA + iptrunk_sideA_ae_members: ae_members_side_a # type: ignore[valid-type] + iptrunk_sideA_ae_members_descriptions: AeMembersDescriptionListA user_input_side_a = yield CreateIptrunkSideAForm - # Remove the selected router for side A, to prevent any loops - routers.pop(str(user_input_side_a.iptrunk_sideA_node_id.name)) - RouterEnumB = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] + routers.pop(str(user_input_router_side_a.iptrunk_sideA_node_id.name)) + router_enum_b = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore + + class SelectRouterSideB(FormPage): + class Config: + title = "Select a router for side B of the trunk." + + iptrunk_sideB_node_id: router_enum_b # type: ignore[valid-type] + + @validator("iptrunk_sideB_node_id", allow_reuse=True) + def validate_device_exists_in_netbox(cls, iptrunk_sideB_node_id: UUIDstr) -> str | NoReturn: + router = Router.from_subscription(iptrunk_sideB_node_id).router + if router.router_vendor == RouterVendor.NOKIA: + device = NetBoxClient().get_device_by_name(router.router_fqdn) + if not device: + raise ValueError("The selected router does not exist in Netbox.") + return iptrunk_sideB_node_id - class AeMembersListB(UniqueConstrainedList[str]): + user_input_router_side_b = yield SelectRouterSideB + router_b = user_input_router_side_b.iptrunk_sideB_node_id.name + side_b_ae_iface = available_lags_choices(router_b) or str + + class AeMembersListB(ChoiceList): + min_items = len(user_input_side_a.iptrunk_sideA_ae_members) + max_items = len(user_input_side_a.iptrunk_sideA_ae_members) + item_type = available_interfaces_choices(router_b, initial_user_input.iptrunk_speed) # type: ignore + unique_items = True + + ae_members_side_b = AeMembersListB if get_router_vendor(router_b) == RouterVendor.NOKIA else JuniperAeMembers + + class AeMembersDescriptionListB(UniqueConstrainedList[str]): min_items = len(user_input_side_a.iptrunk_sideA_ae_members) max_items = len(user_input_side_a.iptrunk_sideA_ae_members) @@ -68,15 +134,20 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: class Config: title = "Provide subscription details for side B of the trunk." - iptrunk_sideB_node_id: RouterEnumB # type: ignore[valid-type] - iptrunk_sideB_ae_iface: str + iptrunk_sideB_ae_iface: side_b_ae_iface # type: ignore[valid-type] iptrunk_sideB_ae_geant_a_sid: str - iptrunk_sideB_ae_members: AeMembersListB - iptrunk_sideB_ae_members_descriptions: AeMembersListB + iptrunk_sideB_ae_members: ae_members_side_b # type: ignore[valid-type] + iptrunk_sideB_ae_members_descriptions: AeMembersDescriptionListB user_input_side_b = yield CreateIptrunkSideBForm - return initial_user_input.dict() | user_input_side_a.dict() | user_input_side_b.dict() + return ( + initial_user_input.dict() + | user_input_side_a.dict() + | user_input_side_b.dict() + | user_input_router_side_a.dict() + | user_input_router_side_b.dict() + ) @step("Create subscription") @@ -161,7 +232,7 @@ def provision_ip_trunk_iface_real(subscription: IptrunkProvisioning, process_id: return { "subscription": subscription, - "label_text": "[COMMIT] Provisioning a trunk interface, please refresh to get the results of the playbook.", + "label_text": "Provisioning a trunk interface, please refresh to get the results of the playbook.", } @@ -205,6 +276,57 @@ def check_ip_trunk_isis(subscription: IptrunkProvisioning, process_id: UUIDstr, } +@step("NextBox integration") +def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: + """Create the LAG interfaces in NetBox and attach the lag interfaces to the physical interfaces.""" + + nbclient = NetBoxClient() + for side in range(0, 2): + if subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_vendor == RouterVendor.NOKIA: + # Create LAG interfaces + lag_interface: Interfaces = nbclient.create_interface( + iface_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_iface, + type="lag", + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + description=str(subscription.subscription_id), + enabled=True, + ) + # Attach physical interfaces to LAG + # Update interface description to subscription ID + # Reserve interfaces + for interface in subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_members: + nbclient.attach_interface_to_lag( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + lag_name=lag_interface.name, + iface_name=interface, + description=str(subscription.subscription_id), + ) + nbclient.reserve_interface( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + iface_name=interface, + ) + return { + "subscription": subscription, + "label_text": "NextBox integration: Reserved interfaces.", + } + + +@step("Allocate interfaces in Netbox") +def allocate_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: + """Allocate the LAG interfaces in NetBox and attach the lag interfaces to the physical interfaces.""" + for side in range(0, 2): + if subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_vendor == RouterVendor.NOKIA: + for interface in subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_members: + NetBoxClient().allocate_interface( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + iface_name=interface, + ) + return { + "subscription": subscription, + "label_text": "NextBox integration: Allocated interfaces.", + } + + @workflow( "Create IP trunk", initial_input_form=wrap_create_initial_input_form(initial_input_form_generator), @@ -217,12 +339,14 @@ def create_iptrunk() -> StepList: >> store_process_subscription(Target.CREATE) >> initialize_subscription >> get_info_from_ipam + >> reserve_interfaces_in_netbox >> pp_interaction(provision_ip_trunk_iface_dry, 3) >> pp_interaction(provision_ip_trunk_iface_real, 3) >> pp_interaction(check_ip_trunk_connectivity, 2, False) >> pp_interaction(provision_ip_trunk_isis_iface_dry, 3) >> pp_interaction(provision_ip_trunk_isis_iface_real, 3) >> pp_interaction(check_ip_trunk_isis, 2, False) + >> allocate_interfaces_in_netbox >> set_status(SubscriptionLifecycle.ACTIVE) >> resync >> done diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 2d28c06e64879a28f3a2d031a42e7a3771705675..74d59ad357f23afbefa114d4e1328e0910518b87 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -157,9 +157,10 @@ def provision_router_real(subscription: RouterProvisioning, process_id: UUIDstr, @step("Create NetBox Device") def create_netbox_device(subscription: RouterProvisioning) -> State: - NetBoxClient().create_device( - subscription.router.router_fqdn, subscription.router.router_site.site_tier # type: ignore - ) + if subscription.router.router_vendor == RouterVendor.NOKIA: + NetBoxClient().create_device( + subscription.router.router_fqdn, subscription.router.router_site.site_tier # type: ignore + ) return {"subscription": subscription} diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py index b02f90cae1f328e148440ffc3f146d29cb1aa198..6c036392da27aa04538e31c1860212a385a7391e 100644 --- a/gso/workflows/router/terminate_router.py +++ b/gso/workflows/router/terminate_router.py @@ -9,6 +9,7 @@ from orchestrator.workflow import StepList, conditional, done, init, step, workf from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form +from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.router import Router from gso.services import infoblox from gso.services.netbox_client import NetBoxClient @@ -61,7 +62,8 @@ def remove_config_from_router() -> None: @step("Remove Device from NetBox") def remove_device_from_netbox(subscription: Router) -> dict[str, Router]: - NetBoxClient().delete_device(subscription.router.router_fqdn) + if subscription.router.router_vendor == RouterVendor.NOKIA: + NetBoxClient().delete_device(subscription.router.router_fqdn) return {"subscription": subscription} diff --git a/gso/workflows/utils.py b/gso/workflows/utils.py index 13e637ad6494bbcb74ba79ffaad4eea63d6228ee..3bfa516349d4fe1cf0764c2bf615eacc9734d10f 100644 --- a/gso/workflows/utils.py +++ b/gso/workflows/utils.py @@ -1,9 +1,13 @@ import re from ipaddress import IPv4Address +from uuid import UUID from orchestrator.forms.validators import Choice +from gso.products.product_blocks.router import RouterVendor +from gso.products.product_types.router import Router from gso.services.crm import all_customers +from gso.services.netbox_client import NetBoxClient def customer_selector() -> Choice: @@ -14,6 +18,48 @@ def customer_selector() -> Choice: return Choice("Select a customer", zip(customers.keys(), customers.items())) # type: ignore[arg-type] +def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: + """Return a list of available interfaces for a given router and speed. + + For Nokia routers, return a list of available interfaces. + For Juniper routers, return a string. + """ + if Router.from_subscription(router_id).router.router_vendor != RouterVendor.NOKIA: + return None + interfaces = { + interface["name"]: f"{interface['name']} - {interface['module']['display']} - {interface['description']}" + for interface in NetBoxClient().get_available_interfaces(router_id, speed) + } + return Choice("ae member", zip(interfaces.keys(), interfaces.items())) # type: ignore[arg-type] + + +def available_lags_choices(router_id: UUID) -> Choice | None: + """Return a list of available lags for a given router. + + For Nokia routers, return a list of available lags. + For Juniper routers, return a string. + """ + + if Router.from_subscription(router_id).router.router_vendor != RouterVendor.NOKIA: + return None + side_a_ae_iface_list = NetBoxClient().get_available_lags(router_id) + return Choice("ae iface", zip(side_a_ae_iface_list, side_a_ae_iface_list)) # type: ignore[arg-type] + + +def get_router_vendor(router_id: UUID) -> str: + """Retrieve the vendor of a router. + + Args: + ---- + router_id (UUID): The {term}`UUID` of the router. + + Returns: + ------- + str: The vendor of the router. + """ + return Router.from_subscription(router_id).router.router_vendor + + def iso_from_ipv4(ipv4_address: IPv4Address) -> str: """Calculate an :term:`ISO` address, based on an IPv4 address.