diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4aa7c82146a7b3e627d678bc7d8034e89c9267eb..ecc1e241209ed32ee98ef7b280b2c74f876cec0d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,7 +2,7 @@ stages: - tox - documentation - + - sonarqube include: - docs/.gitlab-ci.yml @@ -16,6 +16,7 @@ run-tox-pipeline: services: - postgres:15.4 + # Change pip's cache directory to be inside the project directory since we can # only cache local items. variables: @@ -46,3 +47,11 @@ run-tox-pipeline: artifacts: paths: - htmlcov + +sonarqube: + stage: sonarqube + image: sonarsource/sonar-scanner-cli + script: + - sonar-scanner -Dsonar.login=$SONAR_TOKEN -Dproject.settings=./sonar.properties + tags: + - docker-executor diff --git a/gso/services/netbox_client.py b/gso/services/netbox_client.py index 3c27e95e2e9229fb3ff6f7651295acb52767b0f7..7f0253e778102d85f4f8a74a6c2ba8123269ca26 100644 --- a/gso/services/netbox_client.py +++ b/gso/services/netbox_client.py @@ -3,6 +3,7 @@ from uuid import UUID import pydantic import pynetbox +from infoblox_client.objects import Interface from pynetbox.models.dcim import Devices, DeviceTypes, Interfaces from gso.products.product_types.router import Router @@ -53,6 +54,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.""" device = self.netbox.dcim.devices.get(name=device_name) @@ -293,32 +302,16 @@ class NetboxClient: return interface - def deallocate_interface(self, device_name: str, iface_name: str) -> Interfaces: - """Deallocate an interface by marking it as not connected.""" - - # First get interface from device - interface = self.get_interface_by_name_and_by_device_name(iface_name=iface_name, device_name=device_name) - - # Ensure that interface is reserved - if not interface.mark_connected: - raise WorkflowStateError(f"The interface: {iface_name} on device: {device_name} is not allocated.") - - # Deallocate interface by marking it as not connected - interface.mark_connected = False - interface.save() - - return interface - - def update_interface_description(self, device_name: str, iface_name: str, description: str) -> Interfaces: - """Update the description field of an interface.""" - - # First get interface from device - interface = self.get_interface_by_name_and_by_device_name(iface_name=iface_name, device_name=device_name) - - interface.description = description - interface.save() - - return interface + def detach_interfaces_from_lag(self, device_name: str, lag_name: str) -> None: + """Detach all interfaces from a LAG.""" + device = self.get_device_by_name(device_name) + lag = self.netbox.dcim.interfaces.get(device_id=device.id, name=lag_name) + for interface in self.netbox.dcim.interfaces.filter( + device_id=device.id, lag_id=lag.id, enabled=False, mark_connected=False + ): + interface.lag = None + interface.save() + return def get_available_lags(self, router_id: UUID) -> list[str]: """Return all available :term:`LAG`s not assigned to a device.""" @@ -342,6 +335,8 @@ class NetboxClient: interface = self.get_interface_by_name_and_by_device_name(iface_name=iface_name, device_name=device_name) + if interface is None: + raise NotFoundError(f"Interface: {iface_name} on device: {device_name} not found.") if not interface.mark_connected: raise WorkflowStateError(f"The interface: {iface_name} on device: {device_name} is not allocated.") if not interface.enabled: @@ -370,3 +365,13 @@ class NetboxClient: return self.netbox.dcim.interfaces.filter( device=device.name, enabled=False, mark_connected=False, speed=speed_bps ) + + def get_interface_by_name_and_device(self, router_id: UUID, interface_name: str) -> Interface: + """Return the interface object by name and device from netbox, or ``None`` if not found.""" + + router = Router.from_subscription(router_id).router.router_fqdn + device = self.get_device_by_name(router) + try: + return self.netbox.dcim.interfaces.get(device=device.name, name=interface_name) + except pynetbox.RequestError: + raise NotFoundError(f"Interface: {interface_name} on device: {device.name} not found.") diff --git a/gso/services/provisioning_proxy.py b/gso/services/provisioning_proxy.py index 5a6ba11d79fedcfa8431d1dc0c8a283e23c9d73e..c29d942915a537e1a9d64ad0ad449449dd2ae9e0 100644 --- a/gso/services/provisioning_proxy.py +++ b/gso/services/provisioning_proxy.py @@ -103,7 +103,12 @@ def provision_router( def provision_ip_trunk( - subscription: IptrunkProvisioning, process_id: UUIDstr, tt_number: str, config_object: str, dry_run: bool = True + subscription: IptrunkProvisioning, + process_id: UUIDstr, + tt_number: str, + config_object: str, + dry_run: bool = True, + removed_ae_members: list[str] | None = None, ) -> None: """Provision an IP trunk service using :term:`LSO`. @@ -116,6 +121,8 @@ def provision_ip_trunk( :param dry_run: A boolean indicating whether this should be a dry run or not, defaults to `True`. :type dry_run: bool :rtype: None + :param removed_ae_members: A list of interfaces that are removed from the :term:`LAG`, defaults to `None`. + it's only used when we removed some interfaces from the LAG in modify_ip_trunk. """ parameters = { "subscription": json.loads(json_dumps(subscription)), @@ -124,6 +131,7 @@ def provision_ip_trunk( "tt_number": tt_number, "process_id": process_id, "object": config_object, + "removed_ae_members": removed_ae_members, } _send_request("ip_trunk", parameters, process_id, CUDOperation.POST) @@ -175,7 +183,7 @@ def migrate_ip_trunk( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index ff37395b92b3c3d7a8c9b5c61e392afa7b7bbe31..fabfddecfd69aa90151d331bf0c0a205eedda26b 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -7,6 +7,7 @@ from orchestrator.types import State, UUIDstr from pydantic import BaseModel from pydantic_forms.validators import Choice +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 @@ -51,6 +52,30 @@ def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: return Choice("ae member", zip(interfaces.keys(), interfaces.items())) # type: ignore[arg-type] +def available_interfaces_choices_including_current_members( + router_id: UUID, speed: str, interfaces: list[IptrunkInterfaceBlock] +) -> Choice | None: + """Return a list of available interfaces for a given router and speed including the current members. + + 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 + available_interfaces = list(NetboxClient().get_available_interfaces(router_id, speed)) + available_interfaces.extend( + [ + NetboxClient().get_interface_by_name_and_device(router_id, interface.interface_name) + for interface in interfaces + ] + ) + options = { + interface["name"]: f"{interface['name']} - {interface['module']['display']} - {interface['description']}" + for interface in available_interfaces + } + return Choice("ae member", zip(options.keys(), options.items())) # type: ignore[arg-type] + + def available_lags_choices(router_id: UUID) -> Choice | None: """Return a list of available lags for a given router. diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 4b918d00326114ed1da4f1b87a2a055f406bfe52..ef69a304e589ea26e3582074fc3890aaec06a2c5 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -1,10 +1,11 @@ +import copy import re from logging import getLogger from typing import NoReturn +from uuid import uuid4 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 +14,139 @@ 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 pydantic_forms.core import ReadOnlyField +from pynetbox.models.dcim import Interfaces +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 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 ( + LAGMember, + 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 = [ + side.iptrunk_side_node.subscription.subscription_id for side in subscription.iptrunk.iptrunk_sides + ] 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 in get_active_router_subscriptions(includes=["subscription_id", "description"]): + router_id = router["subscription_id"] + 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 + 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 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) - - class NewSideIptrunkForm(FormPage): + class NewSideIPTrunkRouterForm(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}" + 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 + + if get_router_vendor(new_router) == RouterVendor.NOKIA: + + class NokiaLAGMember(LAGMember): + interface_name: available_interfaces_choices( # type: ignore[valid-type] + new_router, subscription.iptrunk.iptrunk_speed ) - new_node: NewRouterEnum # type: ignore[valid-type] - new_lag_interface: str - new_lag_member_interfaces: LagMemberList + class NokiaAeMembers(UniqueConstrainedList[NokiaLAGMember]): + min_items = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) + max_items = len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members) - @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}$") + ae_members = NokiaAeMembers + else: - if nokia_lag_re.match(new_lag_name) or juniper_lag_re.match(new_lag_name): - return new_lag_name + class JuniperLagMember(UniqueConstrainedList[LAGMember]): + min_items = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) + max_items = len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members) - raise ValueError("Invalid LAG name, please try again.") + ae_members = JuniperLagMember # type: ignore[assignment] - new_side_input = yield NewSideIptrunkForm + replace_index = ( + 0 + if str(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.subscription.subscription_id) + == migrate_form_input.replace_side + else 1 + ) + existing_lag_ae_members = [ + {"interface_name": iface.interface_name, "interface_description": iface.interface_description} + for iface in subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members + ] - 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 - raise ValueError("Invalid Router id provided to be replaced!") + class NewSideIPTrunkForm(FormPage): + class Config: + title = form_title - replace_index = _find_updated_side_of_trunk(subscription, old_side_input.replace_side) + new_lag_interface: side_a_ae_iface # type: ignore[valid-type] + existing_lag_interface: list[LAGMember] = ReadOnlyField(existing_lag_ae_members) + new_lag_member_interfaces: ae_members # type: ignore[valid-type] - return old_side_input.dict() | new_side_input.dict() | {"replace_index": replace_index} + @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 + return ( + migrate_form_input.dict() + | new_side_iptrunk_router_input.dict() + | new_side_input.dict() + | {"replace_index": replace_index} + ) @step("[DRY RUN] Disable configuration on old router") @@ -122,7 +154,7 @@ def disable_old_config_dry( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -150,7 +182,7 @@ def disable_old_config_real( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -179,7 +211,7 @@ def deploy_new_config_dry( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -209,7 +241,7 @@ def deploy_new_config_real( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -259,7 +291,7 @@ def deploy_new_isis( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -313,7 +345,7 @@ def delete_old_config_dry( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -344,7 +376,7 @@ def delete_old_config_real( subscription: Iptrunk, new_node: Router, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], replace_index: int, process_id: UUIDstr, tt_number: str, @@ -372,8 +404,6 @@ def delete_old_config_real( @step("Update IPAM") def update_ipam(subscription: Iptrunk) -> State: - pass - return {"subscription": subscription} @@ -383,12 +413,85 @@ def update_subscription_model( replace_index: int, new_node: UUIDstr, new_lag_interface: str, - new_lag_member_interfaces: list[str], + new_lag_member_interfaces: list[dict], ) -> State: + # 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 = new_lag_member_interfaces + subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members.clear() + # And update the list to only include the new member interfaces + for member in new_lag_member_interfaces: + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members.append( + IptrunkInterfaceBlock.new(subscription_id=uuid4(), **member) + ) + + 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[dict], +) -> 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["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("Update Netbox. Allocate new interfaces and deallocate old ones.") +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.interface_name, + ) + 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["interface_name"]) + + # 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 +505,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 +519,7 @@ def migrate_iptrunk() -> StepList: >> pp_interaction(delete_old_config_real, 3) >> update_ipam >> update_subscription_model + >> update_netbox >> resync >> done ) diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 284602504eb7af3d7d7563728fbe401a1ce0f424..26d9c9cc4c14d86f0cf06bf3a412259db09692f4 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -1,4 +1,5 @@ import ipaddress +from typing import List, Type from uuid import uuid4 from orchestrator.forms import FormPage, ReadOnlyField @@ -8,12 +9,50 @@ from orchestrator.types import FormGenerator, State, UUIDstr from orchestrator.workflow import StepList, done, init, step, workflow 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 pydantic_forms.validators import Label from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkType, PhyPortCapacity +from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import Iptrunk 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 LAGMember +from gso.utils.helpers import ( + LAGMember, + available_interfaces_choices, + available_interfaces_choices_including_current_members, + validate_iptrunk_unique_interface, +) + + +def initialize_ae_members(subscription: Iptrunk, initial_user_input: dict, side_index: int) -> Type[LAGMember]: + """Initialize the list of AE members.""" + router = subscription.iptrunk.iptrunk_sides[side_index].iptrunk_side_node + iptrunk_minimum_link = initial_user_input["iptrunk_minimum_links"] + if router.router_vendor == RouterVendor.NOKIA: + iptrunk_speed = initial_user_input["iptrunk_speed"] + + class NokiaLAGMember(LAGMember): + interface_name: available_interfaces_choices_including_current_members( # type: ignore[valid-type] + router.owner_subscription_id, + iptrunk_speed, + subscription.iptrunk.iptrunk_sides[side_index].iptrunk_side_ae_members, + ) if iptrunk_speed == subscription.iptrunk.iptrunk_speed else ( + available_interfaces_choices(router.owner_subscription_id, initial_user_input["iptrunk_speed"]) + ) + + class NokiaAeMembers(UniqueConstrainedList[NokiaLAGMember]): + min_items = iptrunk_minimum_link + + ae_members = NokiaAeMembers + else: + + class JuniperAeMembers(UniqueConstrainedList[LAGMember]): + min_items = iptrunk_minimum_link + + ae_members = JuniperAeMembers # type: ignore[assignment] + return ae_members # type: ignore[return-value] def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -24,6 +63,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: geant_s_sid: str = subscription.iptrunk.geant_s_sid iptrunk_description: str = subscription.iptrunk.iptrunk_description iptrunk_type: IptrunkType = subscription.iptrunk.iptrunk_type + warning_label: Label = ( + "Changing the PhyPortCapacity will result in the deletion of all AE members. " + "You will need to add the new AE members in the next steps." # type: ignore[assignment] + ) iptrunk_speed: PhyPortCapacity = subscription.iptrunk.iptrunk_speed iptrunk_minimum_links: int = subscription.iptrunk.iptrunk_minimum_links iptrunk_isis_metric: int = ReadOnlyField(subscription.iptrunk.iptrunk_isis_metric) @@ -31,9 +74,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: iptrunk_ipv6_network: ipaddress.IPv6Network = ReadOnlyField(subscription.iptrunk.iptrunk_ipv6_network) initial_user_input = yield ModifyIptrunkForm - - class AeMembersListA(UniqueConstrainedList[LAGMember]): - min_items = initial_user_input.iptrunk_minimum_links + ae_members_side_a = initialize_ae_members(subscription, initial_user_input.dict(), 0) class ModifyIptrunkSideAForm(FormPage): class Config: @@ -42,13 +83,18 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: side_a_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn) side_a_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface) side_a_ae_geant_a_sid: str = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid - side_a_ae_members: AeMembersListA = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members + side_a_ae_members: ae_members_side_a = ( # type: ignore[valid-type] + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members + if initial_user_input.iptrunk_speed == subscription.iptrunk.iptrunk_speed + else [] + ) - user_input_side_a = yield ModifyIptrunkSideAForm + @validator("side_a_ae_members", allow_reuse=True) + def validate_iptrunk_unique_interface_side_a(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: + return validate_iptrunk_unique_interface(side_a_ae_members) - class AeMembersListB(UniqueConstrainedList[LAGMember]): - min_items = len(user_input_side_a.side_a_ae_members) - max_items = len(user_input_side_a.side_a_ae_members) + user_input_side_a = yield ModifyIptrunkSideAForm + ae_members_side_b = initialize_ae_members(subscription, initial_user_input.dict(), 1) class ModifyIptrunkSideBForm(FormPage): class Config: @@ -57,7 +103,15 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: side_b_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn) side_b_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_iface) side_b_ae_geant_a_sid: str = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid - side_b_ae_members: AeMembersListB = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members + side_b_ae_members: ae_members_side_b = ( # type: ignore[valid-type] + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members + if initial_user_input.iptrunk_speed == subscription.iptrunk.iptrunk_speed + else [] + ) + + @validator("side_b_ae_members", allow_reuse=True) + def validate_iptrunk_unique_interface_side_b(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: + return validate_iptrunk_unique_interface(side_b_ae_members) user_input_side_b = yield ModifyIptrunkSideBForm @@ -77,6 +131,20 @@ def modify_iptrunk_subscription( side_b_ae_geant_a_sid: str, side_b_ae_members: list[dict], ) -> State: + # Prepare the list of removed AE members + previous_ae_members = {} + removed_ae_members = {} + for side_index in range(2): + previous_ae_members[side_index] = [ + {"interface_name": member.interface_name, "interface_description": member.interface_description} + for member in subscription.iptrunk.iptrunk_sides[side_index].iptrunk_side_ae_members + ] + for side_index in range(2): + previous_members = previous_ae_members[side_index] + current_members = side_a_ae_members if side_index == 0 else side_b_ae_members + removed_ae_members[side_index] = [ + ae_member for ae_member in previous_members if ae_member not in current_members + ] subscription.iptrunk.geant_s_sid = geant_s_sid subscription.iptrunk.iptrunk_description = iptrunk_description subscription.iptrunk.iptrunk_type = iptrunk_type @@ -101,12 +169,20 @@ def modify_iptrunk_subscription( subscription.description = f"IP trunk, geant_s_sid:{geant_s_sid}" - return {"subscription": subscription} + return { + "subscription": subscription, + "removed_ae_members": removed_ae_members, + "previous_ae_members": previous_ae_members, + } @step("Provision IP trunk interface [DRY RUN]") -def provision_ip_trunk_iface_dry(subscription: Iptrunk, process_id: UUIDstr, tt_number: str) -> State: - provisioning_proxy.provision_ip_trunk(subscription, process_id, tt_number, "trunk_interface") +def provision_ip_trunk_iface_dry( + subscription: Iptrunk, process_id: UUIDstr, tt_number: str, removed_ae_members: List[str] +) -> State: + provisioning_proxy.provision_ip_trunk( + subscription, process_id, tt_number, "trunk_interface", True, removed_ae_members + ) return { "subscription": subscription, @@ -115,8 +191,12 @@ def provision_ip_trunk_iface_dry(subscription: Iptrunk, process_id: UUIDstr, tt_ @step("Provision IP trunk interface [FOR REAL]") -def provision_ip_trunk_iface_real(subscription: Iptrunk, process_id: UUIDstr, tt_number: str) -> State: - provisioning_proxy.provision_ip_trunk(subscription, process_id, tt_number, "trunk_interface", False) +def provision_ip_trunk_iface_real( + subscription: Iptrunk, process_id: UUIDstr, tt_number: str, removed_ae_members: List[str] +) -> State: + provisioning_proxy.provision_ip_trunk( + subscription, process_id, tt_number, "trunk_interface", False, removed_ae_members + ) return { "subscription": subscription, @@ -124,6 +204,69 @@ def provision_ip_trunk_iface_real(subscription: Iptrunk, process_id: UUIDstr, tt } +@step("Update interfaces in Netbox. Reserving interfaces.") +def update_interfaces_in_netbox(subscription: Iptrunk, removed_ae_members: dict, previous_ae_members: dict) -> State: + nbclient = NetboxClient() + for side in range(0, 2): + if subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_vendor == RouterVendor.NOKIA: + lag_interface = subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_iface + router_name = subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn + # Free removed interfaces + for member in removed_ae_members[str(side)]: + nbclient.free_interface(router_name, member["interface_name"]) + # 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: + if any( + ae_member.get("interface_name") == interface.interface_name + for ae_member in previous_ae_members[str(side)] + ): + continue + nbclient.attach_interface_to_lag( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + lag_name=lag_interface, + iface_name=interface.interface_name, + description=str(subscription.subscription_id), + ) + nbclient.reserve_interface( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + iface_name=interface.interface_name, + ) + return { + "subscription": subscription, + } + + +@step("Allocate interfaces in Netbox") +def allocate_interfaces_in_netbox(subscription: Iptrunk, previous_ae_members: dict) -> State: + """Allocate the LAG interfaces in NetBox. + + attach the lag interfaces to the physical interfaces detach old ones from the LAG. + """ + + for side in range(0, 2): + nbclient = NetboxClient() + 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: + if any( + ae_member.get("interface_name") == interface.interface_name + for ae_member in previous_ae_members[str(side)] + ): + continue + nbclient.allocate_interface( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + iface_name=interface.interface_name, + ) + # detach the old interfaces from lag + nbclient.detach_interfaces_from_lag( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + lag_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_iface, + ) + + return {"subscription": subscription} + + @workflow( "Modify IP Trunk interface", initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator), @@ -135,8 +278,10 @@ def modify_trunk_interface() -> StepList: >> store_process_subscription(Target.MODIFY) >> unsync >> modify_iptrunk_subscription + >> update_interfaces_in_netbox >> pp_interaction(provision_ip_trunk_iface_dry, 3) >> pp_interaction(provision_ip_trunk_iface_real, 3) + >> allocate_interfaces_in_netbox >> resync >> done ) diff --git a/sonar.properties b/sonar.properties new file mode 100644 index 0000000000000000000000000000000000000000..4933ccded6da45048827c9c5c748ce232806cd70 --- /dev/null +++ b/sonar.properties @@ -0,0 +1,6 @@ +sonar.projectKey=gso +sonar.projectName=GSO +sonar.projectVersion=0.x +sonar.sources=gso +sonar.python.coverage.reportPaths=coverage.xml +sonar.host.url=https://sonarqube.software.geant.org/ \ No newline at end of file diff --git a/test/services/conftest.py b/test/services/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..282ae9edc8521e77cc6b8688670ad5218fa676c2 --- /dev/null +++ b/test/services/conftest.py @@ -0,0 +1,43 @@ +class MockedNetboxClient: + class BaseMockObject: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def get_device_by_name(self): + return self.BaseMockObject(id=1, name="test") + + def get_available_lags(self) -> list[str]: + return [f"LAG{lag}" for lag in range(1, 5)] + + def get_available_interfaces(self): + interfaces = [] + for interface in range(5): + interface_data = { + "name": f"Interface{interface}", + "module": {"display": f"Module{interface}"}, + "description": f"Description{interface}", + } + interfaces.append(interface_data) + return interfaces + + def create_interface(self): + return self.BaseMockObject(id=1, name="test") + + def attach_interface_to_lag(self): + return self.BaseMockObject(id=1, name="test") + + def reserve_interface(self): + return self.BaseMockObject(id=1, name="test") + + def allocate_interface(self): + return {"id": 1, "name": "test"} + + def free_interface(self): + return self.BaseMockObject(id=1, name="test") + + def detach_interfaces_from_lag(self): + return None + + def delete_interface(self): + return None diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index a8f273f77cb87602fcef887599250f0c0eea153c..77ca88fde54d866817cb984e8c703c6bf00e36d8 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -8,6 +8,7 @@ from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity from gso.services.crm import customer_selector, get_customer_by_name from gso.services.subscriptions import get_product_id_by_name from gso.utils.helpers import LAGMember +from test.services.conftest import MockedNetboxClient from test.workflows import ( assert_aborted, assert_complete, @@ -19,42 +20,6 @@ from test.workflows import ( ) -class MockedNetboxClient: - class BaseMockObject: - def __init__(self, **kwargs): - for key, value in kwargs.items(): - setattr(self, key, value) - - def get_device_by_name(self): - return self.BaseMockObject(id=1, name="test") - - def get_available_lags(self) -> list[str]: - return [f"LAG{lag}" for lag in range(1, 5)] - - def get_available_interfaces(self): - interfaces = [] - for interface in range(5): - interface_data = { - "name": f"Interface{interface}", - "module": {"display": f"Module{interface}"}, - "description": f"Description{interface}", - } - interfaces.append(interface_data) - return interfaces - - def create_interface(self): - return self.BaseMockObject(id=1, name="test") - - def attach_interface_to_lag(self): - return self.BaseMockObject(id=1, name="test") - - def reserve_interface(self): - return self.BaseMockObject(id=1, name="test") - - def allocate_interface(self): - return {"id": 1, "name": "test"} - - @pytest.fixture def netbox_client_mock(): # Mock NetboxClient methods diff --git a/test/workflows/iptrunk/test_migrate_iptrunk.py b/test/workflows/iptrunk/test_migrate_iptrunk.py new file mode 100644 index 0000000000000000000000000000000000000000..89eb44f55cb9ea5776487e760d8335101b3980f4 --- /dev/null +++ b/test/workflows/iptrunk/test_migrate_iptrunk.py @@ -0,0 +1,133 @@ +from unittest.mock import patch + +import pytest + +from gso.products import Iptrunk +from gso.utils.helpers import LAGMember +from test.workflows import ( + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, + user_accept_and_assert_suspended, +) +from test.workflows.iptrunk.test_create_iptrunk import MockedNetboxClient + + +@pytest.mark.workflow +@patch("gso.workflows.iptrunk.migrate_iptrunk.provisioning_proxy.migrate_ip_trunk") +@patch("gso.workflows.iptrunk.migrate_iptrunk.provisioning_proxy.provision_ip_trunk") +@patch("gso.services.netbox_client.NetboxClient.get_device_by_name") +@patch("gso.services.netbox_client.NetboxClient.get_available_interfaces") +@patch("gso.services.netbox_client.NetboxClient.get_available_lags") +@patch("gso.services.netbox_client.NetboxClient.create_interface") +@patch("gso.services.netbox_client.NetboxClient.attach_interface_to_lag") +@patch("gso.services.netbox_client.NetboxClient.reserve_interface") +@patch("gso.services.netbox_client.NetboxClient.allocate_interface") +@patch("gso.services.netbox_client.NetboxClient.free_interface") +@patch("gso.services.netbox_client.NetboxClient.delete_interface") +def test_migrate_iptrunk_success( + mocked_delete_interface, + mocked_free_interface, + mocked_allocate_interface, + mocked_reserve_interface, + mocked_attach_interface_to_lag, + mocked_create_interface, + mocked_get_available_lags, + mocked_get_available_interfaces, + mocked_get_device_by_name, + mock_provision_ip_trunk, + mock_migrate_ip_trunk, + iptrunk_subscription_factory, + router_subscription_factory, + faker, +): + # Set up mock return values + mocked_netbox = MockedNetboxClient() + mocked_get_device_by_name.return_value = mocked_netbox.get_device_by_name() + mocked_get_available_interfaces.return_value = mocked_netbox.get_available_interfaces() + mocked_attach_interface_to_lag.return_value = mocked_netbox.attach_interface_to_lag() + mocked_reserve_interface.return_value = mocked_netbox.reserve_interface() + mocked_allocate_interface.return_value = mocked_netbox.allocate_interface() + mocked_free_interface.return_value = mocked_netbox.free_interface() + mocked_create_interface.return_value = mocked_netbox.create_interface() + mocked_get_available_lags.return_value = mocked_netbox.get_available_lags() + mocked_delete_interface.return_value = mocked_netbox.delete_interface() + + product_id = iptrunk_subscription_factory() + old_subscription = Iptrunk.from_subscription(product_id) + new_router = router_subscription_factory() + + # Run workflow + migrate_form_input = [ + {"subscription_id": product_id}, + { + "tt_number": faker.tt_number(), + "replace_side": str( + old_subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.subscription.subscription_id + ), + }, + { + "new_node": new_router, + }, + { + "new_lag_interface": "LAG1", + "new_lag_member_interfaces": [ + LAGMember(interface_name=f"Interface{interface}", interface_description=faker.sentence()) + for interface in range(2) + ], + }, + ] + + result, process_stat, step_log = run_workflow("migrate_iptrunk", migrate_form_input) + assert_suspended(result) + + lso_return = { + "pp_run_results": { + "status": "ok", + "job_id": faker.uuid4(), + "output": "parsed_output", + "return_code": 0, + }, + "confirm": "ACCEPTED", + } + # Resume steps + for _ in range(5): + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + for _ in range(2): + result, step_log = user_accept_and_assert_suspended(process_stat, step_log) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Iptrunk.from_subscription(subscription_id) + + assert "active" == subscription.status + assert mock_provision_ip_trunk.call_count == 2 + assert mock_migrate_ip_trunk.call_count == 7 + # Assert all Netbox calls have been made + # This test case is only for migrating Nokia to Nokia. + # For Juniper to Nokia and Nokia to Juniper, the workflow is different. + assert mocked_create_interface.call_count == 1 # once for creating the LAG on the newly replaced side + assert mocked_reserve_interface.call_count == 2 # Twice for the new interfaces + assert mocked_attach_interface_to_lag.call_count == 2 # Twice for the new interfaces + assert mocked_allocate_interface.call_count == 2 # Twice for the new interfaces + assert mocked_free_interface.call_count == 2 # Twice for the old interfaces + assert mocked_delete_interface.call_count == 1 # once for deleting the LAG on the old replaced side + + # Assert the new side is replaced + assert str(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.subscription.subscription_id) == new_router + assert subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface == "LAG1" + assert len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) == 2 + assert subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members[0].interface_name == "Interface0" + assert subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members[1].interface_name == "Interface1" diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py index 8f39c4d7f784be6c3beea80c3741f7d856271c20..5bda8463adb6200c7a689d963ef16138a73013c1 100644 --- a/test/workflows/iptrunk/test_modify_trunk_interface.py +++ b/test/workflows/iptrunk/test_modify_trunk_interface.py @@ -12,16 +12,40 @@ from test.workflows import ( run_workflow, user_accept_and_assert_suspended, ) +from test.workflows.iptrunk.test_create_iptrunk import MockedNetboxClient @pytest.mark.workflow @patch("gso.workflows.iptrunk.modify_trunk_interface.provisioning_proxy.provision_ip_trunk") +@patch("gso.services.netbox_client.NetboxClient.get_device_by_name") +@patch("gso.services.netbox_client.NetboxClient.get_available_interfaces") +@patch("gso.services.netbox_client.NetboxClient.attach_interface_to_lag") +@patch("gso.services.netbox_client.NetboxClient.reserve_interface") +@patch("gso.services.netbox_client.NetboxClient.allocate_interface") +@patch("gso.services.netbox_client.NetboxClient.free_interface") +@patch("gso.services.netbox_client.NetboxClient.detach_interfaces_from_lag") def test_iptrunk_modify_trunk_interface_success( + mocked_detach_interfaces_from_lag, + mocked_free_interface, + mocked_allocate_interface, + mocked_reserve_interface, + mocked_attach_interface_to_lag, + mocked_get_available_interfaces, + mocked_get_device_by_name, mock_provision_ip_trunk, iptrunk_subscription_factory, faker, ): # Set up mock return values + mocked_netbox = MockedNetboxClient() + mocked_get_device_by_name.return_value = mocked_netbox.get_device_by_name() + mocked_get_available_interfaces.return_value = mocked_netbox.get_available_interfaces() + mocked_attach_interface_to_lag.return_value = mocked_netbox.attach_interface_to_lag() + mocked_reserve_interface.return_value = mocked_netbox.reserve_interface() + mocked_allocate_interface.return_value = mocked_netbox.allocate_interface() + mocked_free_interface.return_value = mocked_netbox.free_interface() + mocked_detach_interfaces_from_lag.return_value = mocked_netbox.detach_interfaces_from_lag() + product_id = iptrunk_subscription_factory() new_sid = faker.geant_sid() new_description = faker.sentence() @@ -31,12 +55,12 @@ def test_iptrunk_modify_trunk_interface_success( new_side_a_sid = faker.geant_sid() new_side_a_ae_members = [ - {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5) + {"interface_name": f"Interface{i}", "interface_description": faker.sentence()} for i in range(5) ] new_side_b_sid = faker.geant_sid() new_side_b_ae_members = [ - {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5) + {"interface_name": f"Interface{i}", "interface_description": faker.sentence()} for i in range(5) ] # Run workflow @@ -86,6 +110,11 @@ def test_iptrunk_modify_trunk_interface_success( assert "active" == subscription.status assert mock_provision_ip_trunk.call_count == 2 + # Assert all Netbox calls have been made + assert mocked_reserve_interface.call_count == 10 # 5 interfaces per side + assert mocked_attach_interface_to_lag.call_count == 10 # 5 interfaces per side + assert mocked_free_interface.call_count == 4 # 2 interfaces per side(The old ones) + assert mocked_detach_interfaces_from_lag.call_count == 2 # 1 time per side # Assert all subscription properties have been updated correctly assert subscription.description == f"IP trunk, geant_s_sid:{new_sid}" diff --git a/tox.ini b/tox.ini index f79b0ea3fca0500ac30d3c9cd9f21a04d453b645..c259455be909abd8c44e5405dff9a21101edf224 100644 --- a/tox.ini +++ b/tox.ini @@ -32,4 +32,7 @@ commands = coverage run --source gso --omit="gso/migrations/*" -m pytest {posargs} coverage xml coverage html - coverage report --fail-under 80 + sh -c "if [ $SKIP_ALL_TESTS -eq 1 ]; then echo 'Skipping coverage report'; else coverage report --fail-under 80; fi" + +allowlist_externals = + sh \ No newline at end of file diff --git a/utils/netboxcli.py b/utils/netboxcli.py index 6e2edcceac74f9bd07b8a84729f1cb3919bfb157..ed50455eb274d718f57e12c8db4559f65898ac0b 100644 --- a/utils/netboxcli.py +++ b/utils/netboxcli.py @@ -189,15 +189,6 @@ def allocate_interface(fqdn: str, iface: str) -> None: click.echo(allocated_iface) -@action.command() -@click.option("--fqdn", help="Device name from where to get interface to edit") -@click.option("--iface", help="Interface name to edit") -def deallocate_interface(fqdn: str, iface: str) -> None: - click.echo(f"Deallocating interface: device={fqdn}, interface name={iface}") - deallocated_iface = NetboxClient().deallocate_interface(fqdn, iface) - click.echo(deallocated_iface) - - @action.command() @click.option("--fqdn", help="Device name from where to get physical interface to attach LAG") @click.option("--lag", help="LAG name to attach physical interface to") @@ -221,7 +212,6 @@ def detach_interface_from_lag(fqdn: str, lag: str, iface: str) -> None: action.add_command(reserve_interface) action.add_command(unreserve_interface) action.add_command(allocate_interface) -action.add_command(deallocate_interface) action.add_command(attach_interface_to_lag) action.add_command(detach_interface_from_lag)