diff --git a/gso/services/netbox_client.py b/gso/services/netbox_client.py index 02f5313a94d88e48447335c7945da5e14ea0aabb..aac06eea66fcd21281e030486c30e3c647fbbf5e 100644 --- a/gso/services/netbox_client.py +++ b/gso/services/netbox_client.py @@ -53,6 +53,14 @@ class NetboxClient: def get_all_devices(self) -> list[Devices]: return list(self.netbox.dcim.devices.all()) + def get_allocated_interfaces_by_gso_subscription(self, device_name: str, subscription_id: UUID) -> list[Interfaces]: + """Return all allocated interfaces of a device by name.""" + + device = self.get_device_by_name(device_name) + return self.netbox.dcim.interfaces.filter( + device_id=device.id, enabled=True, mark_connected=True, description=subscription_id + ) + def get_device_by_name(self, device_name: str) -> Devices: """Return the device object by name from netbox, or ``None`` if not found.""" return self.netbox.dcim.devices.get(name=device_name) @@ -84,6 +92,13 @@ class NetboxClient: description=description, ) + def delete_interface(self, device_name: str, iface_name: str) -> None: + """Delete an interface from a device by name.""" + + device = self.get_device_by_name(device_name) + interface = self.netbox.dcim.interfaces.get(device_id=device.id, name=iface_name) + return interface.delete() + def create_device_type(self, manufacturer: str, model: str, slug: str) -> DeviceTypes: """Create a new device type in Netbox.""" @@ -224,6 +239,43 @@ class NetboxClient: return interface + def free_interface(self, device_name: str, iface_name: str) -> Interfaces: + """Free interface by marking disconnect and disable it.""" + + device = self.get_device_by_name(device_name) + interface = self.netbox.dcim.interfaces.get(device_id=device.id, name=iface_name) + + # Check if interface is available + if interface is None: + raise NotFoundError(f"Interface: {iface_name} on device: {device_name} not found.") + + interface.mark_connected = False + interface.enabled = False + interface.description = "" + interface.save() + + return interface + + def deallocate_interface(self, device_name: str, iface_name: str) -> Interfaces: + """Allocate an interface by marking it as connected.""" + + device = self.get_device_by_name(device_name) + interface = self.netbox.dcim.interfaces.get(device_id=device.id, name=iface_name) + + # Check if interface is available + if interface is None: + raise NotFoundError(f"Interface: {iface_name} on device: {device_name} not found.") + + # Check if interface is reserved + if interface.mark_connected: + raise WorkflowStateError(f"The interface: {iface_name} on device: {device_name} is already allocated.") + + # allocate interface by mark as connected + interface.mark_connected = False + interface.save() + + return interface + def get_available_lags(self, router_id: UUID) -> list[str]: """Return all available :term:`LAG`s not assigned to a device.""" diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 4b918d00326114ed1da4f1b87a2a055f406bfe52..b1e48b57091f0f0ca971b8eab07e94401b064dd0 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -4,7 +4,6 @@ from typing import NoReturn from orchestrator import step, workflow from orchestrator.config.assignee import Assignee -from orchestrator.db import ProductTable, SubscriptionTable from orchestrator.forms import FormPage from orchestrator.forms.validators import Choice, Label, UniqueConstrainedList from orchestrator.targets import Target @@ -13,108 +12,118 @@ from orchestrator.workflow import StepList, done, init, inputstep from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form from pydantic import validator +from pynetbox.models.dcim import Interfaces +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 provisioning_proxy +from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import pp_interaction -from gso.utils.helpers import set_isis_to_90000 +from gso.services.subscriptions import get_active_router_subscriptions +from gso.utils.helpers import available_interfaces_choices, available_lags_choices, get_router_vendor, set_isis_to_90000 logger = getLogger(__name__) def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: subscription = Iptrunk.from_subscription(subscription_id) + form_title = ( + f"Subscription {subscription.iptrunk.geant_s_sid} " + f" from {subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn}" + f" to {subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}" + ) sides_dict = { str(side.iptrunk_side_node.subscription.subscription_id): side.iptrunk_side_node.subscription.description for side in subscription.iptrunk.iptrunk_sides } - ReplacedSide = Choice( + replaced_side_enum = Choice( "Select the side of the IP trunk to be replaced", zip(sides_dict.keys(), sides_dict.items()), # type: ignore[arg-type] ) - class OldSideIptrunkForm(FormPage): + class IPTrunkMigrateForm(FormPage): class Config: - title = ( - f"Subscription {subscription.iptrunk.geant_s_sid} from " - f"{subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn}" - f" to " - f"{subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}" - ) + title = form_title tt_number: str - replace_side: ReplacedSide # type: ignore[valid-type] + replace_side: replaced_side_enum # type: ignore[valid-type] warning_label: Label = "Are we moving to a different Site?" # type: ignore[assignment] - migrate_to_different_site: bool | None = False + migrate_to_different_site: bool = False - old_side_input = yield OldSideIptrunkForm + migrate_form_input = yield IPTrunkMigrateForm + current_routers = [ + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.subscription.subscription_id, + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.subscription.subscription_id, + ] routers = {} - for router_id, router_description in ( - SubscriptionTable.query.join(ProductTable) - .filter( - ProductTable.product_type == "Router", - SubscriptionTable.status == "active", - ) - .with_entities(SubscriptionTable.subscription_id, SubscriptionTable.description) - .all() - ): - if router_id not in [ - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.subscription.subscription_id, - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.subscription.subscription_id, - ]: - current_router = Router.from_subscription(router_id) - old_side_site_id = Router.from_subscription(old_side_input.replace_side).router.router_site + for router_id, router_description in get_active_router_subscriptions(fields=["subscription_id", "description"]): + if router_id not in current_routers: + current_router_site = Router.from_subscription(router_id).router.router_site.subscription + old_side_site = Router.from_subscription(migrate_form_input.replace_side).router.router_site if ( - not old_side_input.migrate_to_different_site - and current_router.router.router_site.subscription.subscription_id != old_side_site_id + migrate_form_input.migrate_to_different_site + and current_router_site.subscription_id == old_side_site.owner_subscription_id ): continue routers[str(router_id)] = router_description - NewRouterEnum = Choice("Select a new router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] + new_router_enum = Choice("Select a new router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] + + class NewSideIPTrunkRouterForm(FormPage): + class Config: + title = form_title + + new_node: new_router_enum # type: ignore[valid-type] + + new_side_iptrunk_router_input = yield NewSideIPTrunkRouterForm + new_router = new_side_iptrunk_router_input.new_node + side_a_ae_iface = available_lags_choices(new_router) or str class LagMemberList(UniqueConstrainedList[str]): min_items = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) max_items = len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members) + item_type = available_interfaces_choices(new_router, subscription.iptrunk.iptrunk_speed) # type: ignore + unique_items = True - class NewSideIptrunkForm(FormPage): - class Config: - title = ( - f"Subscription {subscription.iptrunk.geant_s_sid} from " - f"{subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn} to " - f"{subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}" - ) + class JuniperLagMemberList(UniqueConstrainedList[str]): + min_items = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) + max_items = len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members) + unique_items = True - new_node: NewRouterEnum # type: ignore[valid-type] - new_lag_interface: str - new_lag_member_interfaces: LagMemberList + ae_members_side_a = LagMemberList if get_router_vendor(new_router) == RouterVendor.NOKIA else JuniperLagMemberList - @validator("new_lag_interface", allow_reuse=True, pre=True, always=True) - def lag_interface_proper_name(cls, new_lag_name: str) -> str | NoReturn: - nokia_lag_re = re.compile("^lag-\\d+$") - juniper_lag_re = re.compile("^ae\\d{1,2}$") + class NewSideIPTrunkForm(FormPage): + class Config: + title = form_title - if nokia_lag_re.match(new_lag_name) or juniper_lag_re.match(new_lag_name): - return new_lag_name + new_lag_interface: side_a_ae_iface # type: ignore[valid-type] + new_lag_member_interfaces: ae_members_side_a # type: ignore[valid-type] - raise ValueError("Invalid LAG name, please try again.") + @validator("new_lag_interface", allow_reuse=True, pre=True, always=True) + def lag_interface_proper_name(cls, new_lag_interface: str) -> str | NoReturn: + if get_router_vendor(new_router) == RouterVendor.JUNIPER: + juniper_lag_re = re.compile("^ae\\d{1,2}$") + if not juniper_lag_re.match(new_lag_interface): + raise ValueError("Invalid LAG name, please try again.") + return new_lag_interface - new_side_input = yield NewSideIptrunkForm + new_side_input = yield NewSideIPTrunkForm def _find_updated_side_of_trunk(trunk: Iptrunk, new_side: str) -> int: - sides = trunk.iptrunk.iptrunk_sides - if str(sides[0].iptrunk_side_node.subscription.subscription_id) == new_side: - return 0 - elif str(sides[1].iptrunk_side_node.subscription.subscription_id) == new_side: # noqa: RET505 - return 1 + for side in trunk.iptrunk.iptrunk_sides: + if str(side.iptrunk_side_node.subscription.subscription_id) == new_side: + return trunk.iptrunk.iptrunk_sides.index(side) raise ValueError("Invalid Router id provided to be replaced!") - replace_index = _find_updated_side_of_trunk(subscription, old_side_input.replace_side) - - return old_side_input.dict() | new_side_input.dict() | {"replace_index": replace_index} + return ( + migrate_form_input.dict() + | new_side_iptrunk_router_input.dict() + | new_side_input.dict() + | {"replace_index": _find_updated_side_of_trunk(subscription, migrate_form_input.replace_side)} + ) @step("[DRY RUN] Disable configuration on old router") @@ -385,10 +394,76 @@ def update_subscription_model( new_lag_interface: str, new_lag_member_interfaces: list[str], ) -> State: + old_side_data = { + "iptrunk_side_node": subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node, + "iptrunk_side_ae_iface": subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_iface, + "iptrunk_side_ae_members": 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 = new_lag_member_interfaces + return {"subscription": subscription, "old_side_data": old_side_data} + + +@step("Reserve interfaces in Netbox") +def reserve_interfaces_in_netbox( + subscription: Iptrunk, + new_node: UUIDstr, + new_lag_interface: str, + new_lag_member_interfaces: list[str], +) -> State: + new_side = Router.from_subscription(new_node).router + + nbclient = NetboxClient() + if new_side.router_vendor == RouterVendor.NOKIA: + # Create LAG interfaces + lag_interface: Interfaces = nbclient.create_interface( + iface_name=new_lag_interface, + type="lag", + device_name=new_side.router_fqdn, + description=str(subscription.subscription_id), + enabled=True, + ) + # Attach physical interfaces to 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, + description=str(subscription.subscription_id), + ) + nbclient.reserve_interface( + device_name=new_side.router_fqdn, + iface_name=interface, + ) + return {"subscription": subscription} + + +@step("Update Netbox.") +def update_netbox( + subscription: Iptrunk, + replace_index: int, + old_side_data: dict, +) -> State: + new_side = subscription.iptrunk.iptrunk_sides[replace_index] + nbclient = NetboxClient() + if new_side.iptrunk_side_node.router_vendor == RouterVendor.NOKIA: + for interface in new_side.iptrunk_side_ae_members: + nbclient.allocate_interface( + device_name=new_side.iptrunk_side_node.router_fqdn, + iface_name=interface, + ) + if old_side_data["iptrunk_side_node"]["router_vendor"] == RouterVendor.NOKIA: + # Set interfaces to free + for iface in old_side_data["iptrunk_side_ae_members"]: + nbclient.free_interface(old_side_data["iptrunk_side_node"]["router_fqdn"], iface) + + # Delete LAG interfaces + nbclient.delete_interface( + old_side_data["iptrunk_side_node"]["router_fqdn"], old_side_data["iptrunk_side_ae_iface"] + ) return {"subscription": subscription} @@ -402,6 +477,7 @@ def migrate_iptrunk() -> StepList: init >> store_process_subscription(Target.MODIFY) >> unsync + >> reserve_interfaces_in_netbox >> pp_interaction(set_isis_to_90000, 3) >> pp_interaction(disable_old_config_dry, 3) >> pp_interaction(disable_old_config_real, 3) @@ -415,6 +491,7 @@ def migrate_iptrunk() -> StepList: >> pp_interaction(delete_old_config_real, 3) >> update_ipam >> update_subscription_model + >> update_netbox >> resync >> done )