Skip to content

Migrate iptrunk

A modification workflow that migrates an IP trunk to a different endpoint.

For a trunk that originally connected endpoints A and B, this workflow introduces a new endpoint C. The trunk is then configured to run from A to C. B is then no longer associated with this IP trunk.

In the input form, the operator selects a new router where this trunk should terminate on.

initial_input_form_generator(subscription_id)

Gather input from the operator on the new router that the IP trunk should connect to.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
    """Gather input from the operator on the new router that the IP trunk should connect to."""
    subscription = Iptrunk.from_subscription(subscription_id)
    form_title = (
        f"Subscription {subscription.iptrunk.gs_id} "
        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
    }

    replaced_side_enum = Choice(
        "Select the side of the IP trunk to be replaced",
        zip(sides_dict.keys(), sides_dict.items(), strict=True),  # type: ignore[arg-type]
    )

    class IPTrunkMigrateForm(FormPage):
        model_config = ConfigDict(title=form_title)

        tt_number: TTNumber
        replace_side: replaced_side_enum  # type: ignore[valid-type]
        warning_label: Label = "Are we moving to a different Site?"
        migrate_to_different_site: bool = False
        restore_isis_metric: bool = True

    migrate_form_input = yield IPTrunkMigrateForm

    current_routers = [
        side.iptrunk_side_node.subscription.subscription_id for side in subscription.iptrunk.iptrunk_sides
    ]

    routers = {}
    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 migrate_form_input.migrate_to_different_site
                and current_router_site.subscription_id != old_side_site.owner_subscription_id
            ):
                #  We want to stay on the same site, so all routers that are in different sites get skipped.
                continue
            #  If migrate_to_different_site is true, we can add *all* routers to the result map
            routers[str(router_id)] = router["description"]

    new_router_enum = Choice("Select a new router", zip(routers.keys(), routers.items(), strict=True))  # type: ignore[arg-type]

    class NewSideIPTrunkRouterForm(FormPage):
        model_config = ConfigDict(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 JuniperAEInterface

    new_side_is_nokia = get_router_vendor(new_router) == Vendor.NOKIA
    if new_side_is_nokia:

        class NokiaLAGMember(LAGMember):
            interface_name: available_interfaces_choices(  # type: ignore[valid-type]
                new_router,
                subscription.iptrunk.iptrunk_speed,
            )

        ae_members = Annotated[
            LAGMemberList[NokiaLAGMember],
            Len(
                min_length=len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members),
                max_length=len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members),
            ),
        ]
    else:
        ae_members = Annotated[  # type: ignore[assignment, misc]
            LAGMemberList[JuniperLAGMember],
            Len(
                min_length=len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members),
                max_length=len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members),
            ),
        ]

    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 = [
        LAGMember(
            interface_name=iface.interface_name,
            interface_description=iface.interface_description,
        )
        for iface in subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members
    ]

    class NewSideIPTrunkForm(SubmitFormPage):
        model_config = ConfigDict(title=form_title)

        new_lag_interface: side_a_ae_iface  # type: ignore[valid-type]
        existing_lag_interface: ReadOnlyField(existing_lag_ae_members, default_type=LAGMemberList[LAGMember])  # type: ignore[valid-type]
        new_lag_member_interfaces: ae_members

    new_side_input = yield NewSideIPTrunkForm
    return (
        migrate_form_input.model_dump()
        | new_side_iptrunk_router_input.model_dump()
        | new_side_input.model_dump()
        | {"replace_index": replace_index}
    )

netbox_reserve_interfaces(subscription, new_node, new_lag_interface, new_lag_member_interfaces)

Reserve new interfaces in Netbox, only when the new side's router is a Nokia router.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@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 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 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}

calculate_old_side_data(subscription, replace_index)

Store subscription information of the old side in the state of the workflow for later use.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@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}

check_ip_trunk_optical_levels_pre(subscription)

Check Optical levels on the trunk before migration.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Check Optical PRE levels on the trunk endpoint")
def check_ip_trunk_optical_levels_pre(subscription: Iptrunk) -> LSOState:
    """Check Optical levels on the trunk before migration."""
    extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "optical_pre"}

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_checks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

check_ip_trunk_optical_levels_post(subscription, new_node, new_lag_member_interfaces, replace_index)

Check Optical POST levels on the trunk.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Check Optical POST levels on the trunk endpoint")
def check_ip_trunk_optical_levels_post(
    subscription: Iptrunk, new_node: Router, new_lag_member_interfaces: list[dict], replace_index: int
) -> LSOState:
    """Check Optical POST levels on the trunk."""
    extra_vars = {
        "wfo_ip_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "check": "optical_post",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_checks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

check_ip_trunk_lldp(subscription, new_node, new_lag_member_interfaces, replace_index)

Check LLDP on the new trunk endpoints.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Check LLDP on the trunk endpoints")
def check_ip_trunk_lldp(
    subscription: Iptrunk, new_node: Router, new_lag_member_interfaces: list[dict], replace_index: int
) -> LSOState:
    """Check LLDP on the new trunk endpoints."""
    extra_vars = {
        "wfo_ip_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "check": "lldp",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_checks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

disable_old_config_dry(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Perform a dry run of disabling the old configuration on the routers.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[DRY RUN] Disable configuration on old router")
def disable_old_config_dry(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Perform a dry run of disabling the old configuration on the routers."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "deactivate",
        "config_object": "deactivate",
        "dry_run": True,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

disable_old_config_real(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Disable old configuration on the routers.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[FOR REAL] Disable configuration on old router")
def disable_old_config_real(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Disable old configuration on the routers."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "deactivate",
        "config_object": "deactivate",
        "dry_run": False,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

deploy_new_config_dry(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Perform a dry run of deploying configuration on the new router.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[DRY RUN] Deploy configuration on new router")
def deploy_new_config_dry(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Perform a dry run of deploying configuration on the new router."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "deploy",
        "config_object": "trunk_interface",
        "dry_run": True,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

deploy_new_config_real(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Deploy configuration on the new router.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[FOR REAL] Deploy configuration on new router")
def deploy_new_config_real(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Deploy configuration on the new router."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "deploy",
        "config_object": "trunk_interface",
        "dry_run": False,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

update_remaining_side_bfd_dry(subscription, new_node, replace_index, process_id, tt_number)

Perform a dry run of updating configuration on the remaining router.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[DRY RUN] Update BFD on the remaining side")
def update_remaining_side_bfd_dry(
    subscription: Iptrunk, new_node: Router, replace_index: int, process_id: UUIDstr, tt_number: str
) -> LSOState:
    """Perform a dry run of updating configuration on the remaining router."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "replace_index": replace_index,
        "verb": "update",
        "config_object": "bfd_update",
        "dry_run": True,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Update BFD config.",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

update_remaining_side_bfd_real(subscription, new_node, replace_index, process_id, tt_number)

Update configuration on the remaining router.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[FOR REAL] Update BFD on the remaining side")
def update_remaining_side_bfd_real(
    subscription: Iptrunk, new_node: Router, replace_index: int, process_id: UUIDstr, tt_number: str
) -> LSOState:
    """Update configuration on the remaining router."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "replace_index": replace_index,
        "verb": "update",
        "config_object": "bfd_update",
        "dry_run": False,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Update BFD config.",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

check_ip_trunk_bfd(subscription, new_node, replace_index)

Check BFD session across the new trunk.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Check BFD session over trunk")
def check_ip_trunk_bfd(subscription: Iptrunk, new_node: Router, replace_index: int) -> LSOState:
    """Check BFD session across the new trunk."""
    extra_vars = {
        "wfo_ip_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "check": "bfd",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_checks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

confirm_continue_move_fiber()

Wait for confirmation from an operator that the physical fiber has been moved.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@inputstep("Wait for confirmation", assignee=Assignee.SYSTEM)
def confirm_continue_move_fiber() -> FormGenerator:
    """Wait for confirmation from an operator that the physical fiber has been moved."""

    class ProvisioningResultPage(SubmitFormPage):
        model_config = ConfigDict(title="Please confirm before continuing")

        info_label: Label = "New trunk interface has been deployed, wait for the physical connection to be moved."

    yield ProvisioningResultPage

    return {}

check_ip_trunk_connectivity(subscription, replace_index)

Check successful connectivity across the new trunk.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Check IP connectivity of the trunk")
def check_ip_trunk_connectivity(subscription: Iptrunk, replace_index: int) -> LSOState:
    """Check successful connectivity across the new trunk."""
    extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "ping"}

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_checks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

deploy_new_isis(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Deploy ISIS configuration.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[FOR REAL] Deploy ISIS configuration on new router")
def deploy_new_isis(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Deploy ISIS configuration."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "deploy",
        "config_object": "isis_interface",
        "dry_run": False,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

check_ip_trunk_isis(subscription, replace_index)

Run an Ansible playbook to confirm ISIS adjacency.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Check ISIS adjacency")
def check_ip_trunk_isis(subscription: Iptrunk, replace_index: int) -> LSOState:
    """Run an Ansible playbook to confirm ISIS adjacency."""
    extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "isis"}

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_checks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[1 - replace_index].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

confirm_continue_restore_isis()

Wait for an operator to confirm that the old ISIS metric should be restored.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@inputstep("Wait for confirmation", assignee=Assignee.SYSTEM)
def confirm_continue_restore_isis() -> FormGenerator:
    """Wait for an operator to confirm that the old ISIS metric should be restored."""

    class ProvisioningResultPage(SubmitFormPage):
        model_config = ConfigDict(title="Please confirm before continuing")

        info_label: Label = "ISIS config has been deployed, confirm if you want to restore the old metric."

    yield ProvisioningResultPage

    return {}

restore_isis_metric(subscription, process_id, tt_number, old_isis_metric)

Restore the ISIS metric to its original value.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[FOR REAL] Restore ISIS metric to original value")
def restore_isis_metric(
    subscription: Iptrunk,
    process_id: UUIDstr,
    tt_number: str,
    old_isis_metric: int,
) -> LSOState:
    """Restore the ISIS metric to its original value."""
    subscription.iptrunk.iptrunk_isis_metric = old_isis_metric
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "dry_run": False,
        "verb": "deploy",
        "config_object": "isis_interface",
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Deploy config for "
        f"{subscription.iptrunk.gs_id}",
    }

    return {
        "subscription": subscription,
        "playbook_name": "gap_ansible/playbooks/iptrunks.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

delete_old_config_dry(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Perform a dry run of deleting the old configuration.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[DRY RUN] Delete configuration on old router")
def delete_old_config_dry(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Perform a dry run of deleting the old configuration."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "delete",
        "config_object": "delete",
        "dry_run": True,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

delete_old_config_real(subscription, new_node, new_lag_interface, new_lag_member_interfaces, replace_index, process_id, tt_number)

Delete old configuration from the routers.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("[FOR REAL] Delete configuration on old router")
def delete_old_config_real(
    subscription: Iptrunk,
    new_node: Router,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
    replace_index: int,
    process_id: UUIDstr,
    tt_number: str,
) -> LSOState:
    """Delete old configuration from the routers."""
    extra_vars = {
        "wfo_trunk_json": json.loads(json_dumps(subscription)),
        "new_node": json.loads(json_dumps(new_node)),
        "new_lag_interface": new_lag_interface,
        "new_lag_member_interfaces": new_lag_member_interfaces,
        "replace_index": replace_index,
        "verb": "delete",
        "config_object": "delete",
        "dry_run": False,
        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
        f"- Deploy config for {subscription.iptrunk.gs_id}",
    }

    return {
        "playbook_name": "gap_ansible/playbooks/iptrunks_migration.yaml",
        "inventory": {
            "all": {
                "hosts": {
                    subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn: None,
                    subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn: None,
                    new_node.router.router_fqdn: None,
                }
            }
        },
        "extra_vars": extra_vars,
    }

update_ipam(subscription, replace_index, new_node, new_lag_interface)

Update IPAM resources.

Move the DNS record pointing to the old side of the trunk, to the new side.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Update IP records in IPAM")
def update_ipam(subscription: Iptrunk, replace_index: int, new_node: Router, new_lag_interface: str) -> State:
    """Update IPAM resources.

    Move the DNS record pointing to the old side of the trunk, to the new side.
    """
    v4_addr = subscription.iptrunk.iptrunk_ipv4_network[replace_index]
    # IPv6 networks start with an unused address we need to skip past.
    v6_addr = subscription.iptrunk.iptrunk_ipv6_network[replace_index + 1]

    #  Out with the old
    infoblox.delete_host_by_ip(subscription.iptrunk.iptrunk_ipv4_network[replace_index])
    #  And in with the new
    new_fqdn = f"{new_lag_interface}-0.{new_node.router.router_fqdn}"
    comment = str(subscription.subscription_id)
    infoblox.create_host_by_ip(new_fqdn, "TRUNK", comment, ipv4_address=v4_addr, ipv6_address=v6_addr)

    return {"subscription": subscription}

update_subscription_model(subscription, replace_index, new_node, new_lag_interface, new_lag_member_interfaces)

Update the subscription model in the database.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Update subscription model")
def update_subscription_model(
    subscription: Iptrunk,
    replace_index: int,
    new_node: UUIDstr,
    new_lag_interface: str,
    new_lag_member_interfaces: list[dict],
) -> State:
    """Update the subscription model in the database."""
    # Deep copy of subscription data
    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()
    #  And update the list to only include the new member interfaces
    for member in new_lag_member_interfaces:
        subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_ae_members.append(
            IptrunkInterfaceBlock.new(subscription_id=uuid4(), **member),
        )

    # Take the new site names, and update the subscription description
    side_names = sorted([
        subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name,
        subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name,
    ])
    subscription.description = f"IP trunk {side_names[0]} {side_names[1]}, {subscription.iptrunk.gs_id}"

    return {"subscription": subscription}

netbox_remove_old_interfaces(old_side_data)

Remove the old LAG interface from Netbox, only relevant if the old side is a Nokia router.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Netbox: Remove old LAG interface")
def netbox_remove_old_interfaces(old_side_data: dict) -> State:
    """Remove the old LAG interface from Netbox, only relevant if the old side is a Nokia router."""
    nbclient = NetboxClient()

    for iface in old_side_data["iptrunk_side_ae_members"]:
        nbclient.free_interface(
            old_side_data["iptrunk_side_node"]["router_fqdn"],
            iface["interface_name"],
        )

    nbclient.delete_interface(
        old_side_data["iptrunk_side_node"]["router_fqdn"],
        old_side_data["iptrunk_side_ae_iface"],
    )

    return {}

netbox_allocate_new_interfaces(subscription, replace_index)

Allocate the new LAG interface in Netbox. Only relevant if the new router is a Nokia.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Netbox: Allocate new LAG member interfaces")
def netbox_allocate_new_interfaces(subscription: Iptrunk, replace_index: int) -> State:
    """Allocate the new LAG interface in Netbox. Only relevant if the new router is a Nokia."""
    nbclient = NetboxClient()
    new_side = subscription.iptrunk.iptrunk_sides[replace_index]

    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,
        )

    return {"subscription": subscription}

create_new_sharepoint_checklist(subscription, tt_number, process_id)

Create a new checklist item in SharePoint for approving this migrated IP trunk.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Create a new SharePoint checklist item")
def create_new_sharepoint_checklist(subscription: Iptrunk, tt_number: str, process_id: UUIDstr) -> State:
    """Create a new checklist item in SharePoint for approving this migrated IP trunk."""
    new_list_item_url = SharePointClient().add_list_item(
        list_name="ip_trunk",
        fields={
            "Title": f"{subscription.description} - {subscription.iptrunk.gs_id}",
            "TT_NUMBER": tt_number,
            "GAP_PROCESS_URL": f"{load_oss_params().GENERAL.public_hostname}/workflows/{process_id}",
            "ACTIVITY_TYPE": "Migration",
        },
    )

    return {"checklist_url": new_list_item_url}

modify_connection_strategy_to_out_of_band(subscription, replace_index)

Modify the replacement side of the IP trunk to use the out-of-band connection strategy.

This is necessary when the router is connected to only one IP trunk.

Note: The router retrieved from subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node will be replaced and is no longer part of this subscription. Because of this, it does not get updated automatically. To ensure consistency, we need to manually update it by calling router.save(). Alternatively, we could run a separate asynchronous modify_connection_strategy migration, but we have opted for the manual update approach here.

Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@step("Update the subscription model to use the out-of-band connection")
def modify_connection_strategy_to_out_of_band(subscription: Iptrunk, replace_index: int) -> State:
    """Modify the replacement side of the IP trunk to use the out-of-band connection strategy.

    This is necessary when the router is connected to only one IP trunk.

    Note:
    The `router` retrieved from `subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node`
    will be replaced and is no longer part of this subscription. Because of this, it does not get updated
    automatically. To ensure consistency, we need to manually update it by calling `router.save()`.
    Alternatively, we could run a separate asynchronous `modify_connection_strategy` migration,
    but we have opted for the manual update approach here.
    """
    iptrunk_side_node = subscription.iptrunk.iptrunk_sides[replace_index].iptrunk_side_node
    iptrunk_side_node.router_access_via_ts = True

    iptrunk_side_node.save(
        subscription_id=iptrunk_side_node.owner_subscription_id, status=iptrunk_side_node.subscription.status
    )

    return {"subscription": subscription}

migrate_iptrunk()

Migrate an IP trunk.

  • Reserve new interfaces in Netbox
  • Set the ISIS metric of the current trunk to an arbitrarily high value to drain all traffic
  • Disable - but do not delete - the old configuration on the routers, first as a dry run
  • Deploy the new configuration on the routers, first as a dry run
  • Wait for operator confirmation that the physical fiber has been moved before continuing
  • Deploy a new ISIS interface between routers A and C
  • Wait for operator confirmation that ISIS is behaving as expected
  • Restore the old ISIS metric on the new trunk
  • Delete the old configuration from the routers, first as a dry run
  • Reflect the changes made in IPAM
  • Update the subscription model in the database
  • Update the reserved interfaces in Netbox
Source code in gso/workflows/iptrunk/migrate_iptrunk.py
@workflow(
    "Migrate an IP Trunk",
    initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
    target=Target.MODIFY,
)
def migrate_iptrunk() -> StepList:
    """Migrate an IP trunk.

    * Reserve new interfaces in Netbox
    * Set the ISIS metric of the current trunk to an arbitrarily high value to drain all traffic
    * Disable - but do not delete - the old configuration on the routers, first as a dry run
    * Deploy the new configuration on the routers, first as a dry run
    * Wait for operator confirmation that the physical fiber has been moved before continuing
    * Deploy a new ISIS interface between routers A and C
    * Wait for operator confirmation that ISIS is behaving as expected
    * Restore the old ISIS metric on the new trunk
    * Delete the old configuration from the routers, first as a dry run
    * Reflect the changes made in IPAM
    * Update the subscription model in the database
    * Update the reserved interfaces in Netbox
    """
    new_side_is_nokia = conditional(lambda state: get_router_vendor(state["new_node"]) == Vendor.NOKIA)
    old_side_is_nokia = conditional(
        lambda state: get_router_vendor(state["old_side_data"]["iptrunk_side_node"]["owner_subscription_id"])
        == Vendor.NOKIA
    )
    should_restore_isis_metric = conditional(lambda state: state["restore_isis_metric"])
    trunk_type_is_leased = conditional(
        lambda state: state["subscription"]["iptrunk"]["iptrunk_type"] == IptrunkType.LEASED
    )

    def _is_router_connected_to_one_ip_trunk(state: State) -> bool:
        iptrunk = Iptrunk.from_subscription(state["subscription"]["subscription_id"])
        router_id = iptrunk.iptrunk.iptrunk_sides[state["replace_index"]].iptrunk_side_node.owner_subscription_id
        return len(get_active_ip_trunks_linked_to_router(str(router_id))) == 1

    if_router_connected_to_one_ip_trunk = conditional(_is_router_connected_to_one_ip_trunk)

    return (
        begin
        >> store_process_subscription(Target.MODIFY)
        >> unsync
        >> new_side_is_nokia(netbox_reserve_interfaces)
        >> calculate_old_side_data
        >> lso_interaction(set_isis_to_max)
        >> lso_interaction(check_ip_trunk_optical_levels_pre)
        >> if_router_connected_to_one_ip_trunk(modify_connection_strategy_to_out_of_band)
        >> lso_interaction(disable_old_config_dry)
        >> lso_interaction(disable_old_config_real)
        >> lso_interaction(deploy_new_config_dry)
        >> lso_interaction(deploy_new_config_real)
        >> trunk_type_is_leased(lso_interaction(update_remaining_side_bfd_dry))
        >> trunk_type_is_leased(lso_interaction(update_remaining_side_bfd_real))
        >> confirm_continue_move_fiber
        >> lso_interaction(check_ip_trunk_optical_levels_post)
        >> lso_interaction(check_ip_trunk_lldp)
        >> trunk_type_is_leased(lso_interaction(check_ip_trunk_bfd))
        >> lso_interaction(check_ip_trunk_connectivity)
        >> lso_interaction(deploy_new_isis)
        >> lso_interaction(check_ip_trunk_isis)
        >> lso_interaction(delete_old_config_dry)
        >> lso_interaction(delete_old_config_real)
        >> update_ipam
        >> update_subscription_model
        >> should_restore_isis_metric(confirm_continue_restore_isis)
        >> should_restore_isis_metric(lso_interaction(restore_isis_metric))
        >> old_side_is_nokia(netbox_remove_old_interfaces)
        >> new_side_is_nokia(netbox_allocate_new_interfaces)
        >> resync
        >> create_new_sharepoint_checklist
        >> prompt_sharepoint_checklist_url
        >> done
    )