diff --git a/gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py b/gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..0dfcd2cbee3f627b93c2d1d38247cd3a2ca72699 --- /dev/null +++ b/gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py @@ -0,0 +1,39 @@ +"""Add L2Circuit migration workflow. + +Revision ID: 3541c7e57284 +Revises: b14f71db2b58 +Create Date: 2025-03-27 15:35:39.351436 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '3541c7e57284' +down_revision = 'b14f71db2b58' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import create_workflow, delete_workflow + +new_workflows = [ + { + "name": "migrate_layer_2_circuit", + "target": "MODIFY", + "description": "Migrate Layer 2 Circuit", + "product_type": "Layer2Circuit" + } +] + + +def upgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + create_workflow(conn, workflow) + + +def downgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + delete_workflow(conn, workflow["name"]) diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json index b1a54748dbd43be1fc98ca3145b467e097f9f14a..02ea8449fd9eefba694548940ab8b83a6a61a85d 100644 --- a/gso/translations/en-GB.json +++ b/gso/translations/en-GB.json @@ -114,6 +114,7 @@ "import_switch": "NOT FOR HUMANS -- Finalize import into a Switch", "migrate_edge_port": "Migrate Edge Port", "migrate_iptrunk": "Migrate IP Trunk", + "migrate_layer_2_circuit": "Migrate Layer 2 Circuit", "migrate_l3_core_service": "Migrate L3 Core Service", "modify_connection_strategy": "Modify connection strategy", "modify_edge_port": "Modify Edge Port", diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index 15b9fcd42b193127797d06f7e771519efc3ff191..c20e8a4e780daff69688fa1b83b7cf57787ce99a 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -3,7 +3,7 @@ import random import re from ipaddress import IPv4Network, IPv6Network -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias, cast from uuid import UUID from orchestrator.types import SubscriptionLifecycle @@ -11,6 +11,7 @@ from pydantic_forms.types import UUIDstr from pydantic_forms.validators import Choice from gso import settings +from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import Router from gso.services.netbox_client import NetboxClient @@ -267,15 +268,18 @@ def active_switch_selector() -> Choice: return Choice("Select a switch", zip(switch_subscriptions.keys(), switch_subscriptions.items(), strict=True)) # type: ignore[arg-type] -def active_edge_port_selector(*, partner_id: UUIDstr | None = None) -> Choice: +def active_edge_port_selector(*, partner_id: UUIDstr | None = None) -> TypeAlias: """Generate a dropdown selector for choosing an active Edge Port in an input form.""" edge_ports = get_active_edge_port_subscriptions(partner_id=partner_id) options = {str(edge_port.subscription_id): edge_port.description for edge_port in edge_ports} - return Choice( - "Select an Edge Port", - zip(options.keys(), options.items(), strict=True), # type: ignore[arg-type] + return cast( + type[Choice], + Choice.__call__( + "Select an Edge Port", + zip(options.keys(), options.items(), strict=True), + ), ) @@ -301,14 +305,17 @@ def validate_edge_port_number_of_members_based_on_lacp(*, number_of_members: int raise ValueError(err_msg) -def generate_unique_vc_id(max_attempts: int = 100) -> VC_ID | None: - """Generate a unique 8-digit VC_ID starting with '11'. +def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | None: + """Generate a unique 8-digit VC_ID. - This function attempts to generate an 8-digit VC_ID beginning with '11', - checking its uniqueness before returning it. A maximum attempt limit is - set to prevent infinite loops in case the ID space is saturated. + This function attempts to generate a ``VC_ID`` based on their circuit type, + and ensures uniqueness before returning it. A maximum attempt limit is + set to prevent an infinite loop in case the ID space is saturated. + The range used for generating a ``VC_ID`` depends on the circuit type: + ``Ethernet`` and ``VLAN`` type circuits get their IDs from different ranges. Args: + l2c_type: type of l2circuit. max_attempts: The maximum number of attempts to generate a unique ID. Returns: @@ -317,7 +324,9 @@ def generate_unique_vc_id(max_attempts: int = 100) -> VC_ID | None: def create_vc_id() -> str: """Generate an 8-digit VC_ID starting with '11'.""" - return f"11{random.randint(100000, 999999)}" # noqa: S311 + if l2c_type == Layer2CircuitType.ETHERNET: + return str(random.randint(30001, 39999)) + return str(random.randint(6000, 6999)) for _ in range(max_attempts): vc_id = create_vc_id() diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py index 0f1abab7165f2db22138765e51591705cf04ae55..e977c62c25e434852c999d91cd0b2239a3da3e6b 100644 --- a/gso/workflows/__init__.py +++ b/gso/workflows/__init__.py @@ -132,6 +132,7 @@ LazyWorkflowInstance("gso.workflows.l3_core_service.validate_prefix_list", "vali LazyWorkflowInstance("gso.workflows.l2_circuit.create_layer_2_circuit", "create_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.modify_layer_2_circuit", "modify_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.terminate_layer_2_circuit", "terminate_layer_2_circuit") +LazyWorkflowInstance("gso.workflows.l2_circuit.migrate_layer_2_circuit", "migrate_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.create_imported_layer_2_circuit", "create_imported_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.import_layer_2_circuit", "import_layer_2_circuit") diff --git a/gso/workflows/edge_port/migrate_edge_port.py b/gso/workflows/edge_port/migrate_edge_port.py index afd2d6d0f46e9dd6a81f9fe1e290af0ff3b217d0..ecdabd6f3460343249e98b59bd87557514ebdeb5 100644 --- a/gso/workflows/edge_port/migrate_edge_port.py +++ b/gso/workflows/edge_port/migrate_edge_port.py @@ -292,7 +292,7 @@ def migrate_l3_core_services_to_new_node(subscription_id: UUIDstr, tt_number: TT }, ], ], - countdown=random.choice([2, 3, 4, 5]), # noqa: S311 + countdown=random.choice([2, 3, 4, 5]), ) return {"l3_core_services": l3_core_services} @@ -319,7 +319,7 @@ def migrate_l2_circuits_to_new_node(subscription_id: UUIDstr, tt_number: TTNumbe }, ], ], - countdown=random.choice([2, 3, 4, 5]), # noqa: S311 + countdown=random.choice([2, 3, 4, 5]), ) return {"layer2_circuits": layer2_circuits} diff --git a/gso/workflows/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py index 5138aef1a167fe90a39c96bc0abaf557aa9ec875..ce70f215fa60ee80cc9a7a4f0240bec8729a370e 100644 --- a/gso/workflows/l2_circuit/create_layer_2_circuit.py +++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py @@ -18,6 +18,7 @@ from gso.products.product_blocks.layer_2_circuit import Layer2CircuitSideBlockIn from gso.products.product_blocks.service_binding_port import ServiceBindingPortInactive from gso.products.product_types.edge_port import EdgePort from gso.products.product_types.layer_2_circuit import Layer2Circuit, Layer2CircuitInactive +from gso.services.lso_client import lso_interaction from gso.services.partners import get_partner_by_name from gso.services.subscriptions import generate_unique_id from gso.utils.helpers import active_edge_port_selector, generate_unique_vc_id, partner_choice @@ -25,6 +26,11 @@ from gso.utils.shared_enums import SBPType from gso.utils.types.interfaces import BandwidthString from gso.utils.types.tt_number import TTNumber from gso.utils.types.virtual_identifiers import VLAN_ID +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + provision_l2circuit_dry, + provision_l2circuit_real, +) def initial_input_generator(product_name: str) -> FormGenerator: @@ -125,8 +131,10 @@ def initialize_subscription( layer2_circuit_side = Layer2CircuitSideBlockInactive.new(uuid4(), sbp=sbp) layer_2_circuit_sides.append(layer2_circuit_side) subscription.layer_2_circuit.layer_2_circuit_sides = layer_2_circuit_sides - subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id() subscription.layer_2_circuit.layer_2_circuit_type = layer_2_circuit_type + subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id( + l2c_type=subscription.layer_2_circuit.layer_2_circuit_type + ) subscription.layer_2_circuit.vlan_range_lower_bound = vlan_range_lower_bound subscription.layer_2_circuit.vlan_range_upper_bound = vlan_range_upper_bound subscription.layer_2_circuit.policer_enabled = policer_enabled @@ -136,8 +144,9 @@ def initialize_subscription( subscription.description = f"{subscription.product.name} - {subscription.layer_2_circuit.virtual_circuit_id}" subscription = Layer2Circuit.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) + fqdn_list = [side.sbp.edge_port.node.router_fqdn for side in subscription.layer_2_circuit.layer_2_circuit_sides] - return {"subscription": subscription} + return {"subscription": subscription, "fqdn_list": fqdn_list} @workflow( @@ -152,6 +161,9 @@ def create_layer_2_circuit() -> StepList: >> create_subscription >> store_process_subscription(Target.CREATE) >> initialize_subscription + >> extract_partner_name_from_edge_port + >> lso_interaction(provision_l2circuit_dry) + >> lso_interaction(provision_l2circuit_real) >> set_status(SubscriptionLifecycle.ACTIVE) >> resync >> done diff --git a/gso/workflows/l2_circuit/migrate_layer2_circuit.py b/gso/workflows/l2_circuit/migrate_layer2_circuit.py deleted file mode 100644 index e7d4d9b294cce4bd0ab5d8b2b0926fa28567a967..0000000000000000000000000000000000000000 --- a/gso/workflows/l2_circuit/migrate_layer2_circuit.py +++ /dev/null @@ -1,20 +0,0 @@ -"""This workflow migrates an L2 Core Service to a new Edge Port. - -It can be triggered by an operator or automatically by the system during Edge Port migration which is a separate -workflow. - -System-triggered migration: -When the system migrates an Edge Port, it runs the workflow automatically. The source and destination Edge Ports are -set to the same values. Then here migration only applies the configuration to the router and fill the drift between -core DB as source of truth and the actual network since the intent of network has changed in the previous workflow -even though the L2 Circuit Service is not changed. - -Operator-triggered migration: -When an operator initiates the workflow, they are required to specify both the source and destination EdgePorts. -During the migration process, the system updates the related edge_port reference to replace the source -EdgePort with the destination EdgePort and applies the necessary configuration changes to the router. - -Important Note: -Since an L2 Circuit Service has multiple side, the workflow must be run separately for each side to fully -migrate the service. -""" diff --git a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py new file mode 100644 index 0000000000000000000000000000000000000000..70a91e5aaaf24c274ca510bb9121e3b2ac37a882 --- /dev/null +++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py @@ -0,0 +1,152 @@ +"""This workflow migrates an L2 Core Service to a new Edge Port. + +It can be triggered by an operator or automatically by the system during Edge Port migration which is a separate +workflow. + +System-triggered migration: +When the system migrates an Edge Port, it runs the workflow automatically. The source and destination Edge Ports are +set to the same values. Then here migration only applies the configuration to the router and fill the drift between +core DB as source of truth and the actual network since the intent of network has changed in the previous workflow +even though the L2 Circuit Service is not changed. + +Operator-triggered migration: +When an operator initiates the workflow, they are required to specify both the source and destination EdgePorts. +During the migration process, the system updates the related edge_port reference to replace the source +EdgePort with the destination EdgePort and applies the necessary configuration changes to the router. + +!!! info + Since an L2 Circuit Service has two sides, the workflow must be run separately for each side to fully + migrate the service. +""" + +from typing import TypeAlias, cast + +from orchestrator import step, workflow +from orchestrator.forms import FormPage, SubmitFormPage +from orchestrator.forms.validators import Choice, Label +from orchestrator.targets import Target +from orchestrator.utils.errors import ProcessFailureError +from orchestrator.workflow import StepList, begin, conditional, done +from orchestrator.workflows.steps import resync, store_process_subscription, unsync +from orchestrator.workflows.utils import wrap_modify_initial_input_form +from pydantic import ConfigDict, Field +from pydantic_forms.types import FormGenerator, State, UUIDstr +from pydantic_forms.validators import Divider + +from gso.products.product_types.edge_port import EdgePort +from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import lso_interaction +from gso.services.partners import get_partner_by_id +from gso.utils.helpers import active_edge_port_selector, generate_unique_vc_id +from gso.utils.types.tt_number import TTNumber +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + generate_fqdn_list, + provision_l2circuit_dry, + provision_l2circuit_real, + terminate_l2circuit_dry, + terminate_l2circuit_real, +) + + +def input_form_generator(subscription_id: UUIDstr) -> FormGenerator: + """Generate an input form for migrating a Layer 2 Circuit.""" + subscription = Layer2Circuit.from_subscription(subscription_id) + + def circuit_side_selector() -> TypeAlias: + sides_dict = { + str(side.sbp.edge_port.owner_subscription_id): EdgePort.from_subscription( + side.sbp.edge_port.owner_subscription_id + ).description + for side in subscription.layer_2_circuit.layer_2_circuit_sides + } + return cast( + type[Choice], + Choice.__call__("Select one side of the circuit", zip(sides_dict.keys(), sides_dict.items(), strict=True)), + ) + + class MigrateL2CircuitForm(FormPage): + model_config = ConfigDict(title="Migrating Layer 2 Circuit") + + tt_number: TTNumber + replace_side: circuit_side_selector() # type: ignore[valid-type] + divider: Divider = Field(None, exclude=True) + + label_a: Label = Field("Are we migrating to a different site?", exclude=True) + migrate_to_different_site: bool = False + + label_b: Label = Field("Execute Ansible playbooks on the OLD side of the circuit?", exclude=True) + run_old_side_ansible: bool = True + label_c: Label = Field("Execute Ansible playbooks on the NEW side of the circuit?", exclude=True) + run_new_side_ansible: bool = True + + initial_user_input = yield MigrateL2CircuitForm + replace_side_partner = get_partner_by_id(EdgePort.from_subscription(initial_user_input.replace_side).customer_id) + + class SelectNewEdgePortForm(SubmitFormPage): + model_config = ConfigDict(title="Migrating Layer 2 Circuit") + + new_edge_port: active_edge_port_selector(partner_id=replace_side_partner.partner_id) # type: ignore[valid-type] + + user_input = yield SelectNewEdgePortForm + + return { + "tt_number": initial_user_input.tt_number, + "run_old_side_ansible": initial_user_input.run_old_side_ansible, + "run_new_side_ansible": initial_user_input.run_new_side_ansible, + "subscription": subscription, + "subscription_id": subscription_id, + "old_edge_port": initial_user_input.replace_side, + "new_edge_port": user_input.new_edge_port, + } + + +@step("Update subscription model") +def update_subscription_model(subscription: Layer2Circuit, old_edge_port: UUIDstr, new_edge_port: UUIDstr) -> State: + """Replace the old Edge Port with the newly selected one in the subscription model.""" + replace_index = ( + 0 + if str(subscription.layer_2_circuit.layer_2_circuit_sides[0].sbp.edge_port.owner_subscription_id) + == old_edge_port + else 1 + ) + subscription.layer_2_circuit.layer_2_circuit_sides[replace_index].sbp.edge_port = EdgePort.from_subscription( + new_edge_port + ).edge_port + + vc_id = generate_unique_vc_id(l2c_type=subscription.layer_2_circuit.layer_2_circuit_type) + if not vc_id: + msg = "Failed to generate unique Virtual Circuit ID." + raise ProcessFailureError(msg) + + subscription.layer_2_circuit.virtual_circuit_id = vc_id + + return {"subscription": subscription} + + +@workflow( + "Migrate Layer 2 Circuit", + initial_input_form=wrap_modify_initial_input_form(input_form_generator), + target=Target.MODIFY, +) +def migrate_layer_2_circuit() -> StepList: + """Migrate a Layer 2 Circuit.""" + run_old_side_ansible = conditional(lambda state: state["run_old_side_ansible"]) + run_new_side_ansible = conditional(lambda state: state["run_new_side_ansible"]) + + return ( + begin + >> store_process_subscription(Target.MODIFY) + >> unsync + >> run_old_side_ansible(generate_fqdn_list) + >> run_old_side_ansible(extract_partner_name_from_edge_port) + >> run_old_side_ansible(lso_interaction(terminate_l2circuit_dry)) + >> run_old_side_ansible(lso_interaction(terminate_l2circuit_real)) + >> update_subscription_model + >> run_new_side_ansible(generate_fqdn_list) + >> run_new_side_ansible(extract_partner_name_from_edge_port) + >> run_new_side_ansible(lso_interaction(provision_l2circuit_dry)) + >> run_new_side_ansible(lso_interaction(provision_l2circuit_real)) + >> resync + >> done + ) diff --git a/gso/workflows/l2_circuit/modify_layer_2_circuit.py b/gso/workflows/l2_circuit/modify_layer_2_circuit.py index 91c791359b99194e9e089e7a0823b3b857aa37af..ae879037c0700a515ca7d2507fbad6eba7d83715 100644 --- a/gso/workflows/l2_circuit/modify_layer_2_circuit.py +++ b/gso/workflows/l2_circuit/modify_layer_2_circuit.py @@ -3,7 +3,7 @@ from orchestrator import begin, done, workflow from orchestrator.forms import FormPage, SubmitFormPage from orchestrator.targets import Target -from orchestrator.workflow import StepList, step +from orchestrator.workflow import StepList, conditional, step from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form from pydantic import BaseModel, ConfigDict, Field @@ -13,10 +13,17 @@ from pydantic_forms.validators import Divider, Label, ReadOnlyField from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_types.edge_port import EdgePort from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import lso_interaction from gso.services.partners import get_partner_by_id from gso.utils.types.interfaces import BandwidthString from gso.utils.types.tt_number import TTNumber from gso.utils.types.virtual_identifiers import VLAN_ID +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + generate_fqdn_list, + provision_l2circuit_dry, + provision_l2circuit_real, +) def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -36,6 +43,9 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: policer_enabled: bool = subscription.layer_2_circuit.policer_enabled custom_service_name: str | None = subscription.layer_2_circuit.custom_service_name + label: Label = Field("Should this workflow execute Ansible playbooks on routers?", exclude=True) + run_ansible_steps: bool = True + layer_2_circuit_input = yield ModifyL2CircuitForm class ModifyLayer2CircuitServiceSidesPage(SubmitFormPage): @@ -127,11 +137,17 @@ def modify_layer_2_circuit_subscription( ) def modify_layer_2_circuit() -> StepList: """Modify an existing Layer 2 Circuit service subscription.""" + run_ansible_steps = conditional(lambda state: state["run_ansible_steps"]) + return ( begin >> store_process_subscription(Target.MODIFY) >> unsync + >> run_ansible_steps(generate_fqdn_list) + >> run_ansible_steps(extract_partner_name_from_edge_port) >> modify_layer_2_circuit_subscription + >> run_ansible_steps(lso_interaction(provision_l2circuit_dry)) + >> run_ansible_steps(lso_interaction(provision_l2circuit_real)) >> resync >> done ) diff --git a/gso/workflows/l2_circuit/shared_steps.py b/gso/workflows/l2_circuit/shared_steps.py new file mode 100644 index 0000000000000000000000000000000000000000..48b6429b6405afdfc1f46fcbed48f4dfe4a99c3c --- /dev/null +++ b/gso/workflows/l2_circuit/shared_steps.py @@ -0,0 +1,121 @@ +"""Workflow steps that are used in multiple Layer 2 Circuit workflows.""" + +from copy import deepcopy +from typing import Any + +from orchestrator import step +from pydantic_forms.types import State, UUIDstr + +from gso.products.product_types.edge_port import EdgePort +from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import LSOState +from gso.services.partners import get_partner_by_id +from gso.utils.types.tt_number import TTNumber + + +@step("Generate FQDN list") +def generate_fqdn_list(subscription: Layer2Circuit) -> State: + """Generate the list of FQDNs that this workflow should target. + + This list will consist of two elements, one for each far end of the circuit. + """ + return { + "fqdn_list": [ + side.sbp.edge_port.node.router_fqdn for side in subscription.layer_2_circuit.layer_2_circuit_sides + ] + } + + +@step("Expand subscription dictionary") +def extract_partner_name_from_edge_port(subscription: dict[str, Any]) -> State: + """Expand a subscription model of a Layer 2 Circuit. + + This method will include the name of each Edge Port's partner to be used in Ansible playbooks. + """ + modified_subscription = deepcopy(subscription) + for side in modified_subscription["layer_2_circuit"]["layer_2_circuit_sides"]: + side["sbp"]["edge_port"]["partner_name"] = get_partner_by_id( + EdgePort.from_subscription(side["sbp"]["edge_port"]["owner_subscription_id"]).customer_id + ).name + + return {"modified_subscription": modified_subscription} + + +@step("[DRY RUN] Deploy L2circuit") +def provision_l2circuit_dry( + modified_subscription: dict[str, Any], process_id: UUIDstr, tt_number: str, fqdn_list: list[str] +) -> LSOState: + """Perform a dry run of deploying a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": True, + "verb": "deploy", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Deploy config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } + + +@step("[REAL RUN] Deploy L2circuit") +def provision_l2circuit_real( + modified_subscription: dict[str, Any], process_id: UUIDstr, tt_number: str, fqdn_list: list[str] +) -> LSOState: + """Perform a dry run of deploying a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": False, + "verb": "deploy", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Deploy config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } + + +@step("[DRY RUN] Remove old config") +def terminate_l2circuit_dry( + process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str] +) -> LSOState: + """Perform a dry run of removing old configuration of a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": True, + "verb": "terminate", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Remove config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } + + +@step("[FOR REAL] Remove old config") +def terminate_l2circuit_real( + process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str] +) -> LSOState: + """Remove old configuration of a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": False, + "verb": "terminate", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Remove config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } diff --git a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py index e8773a678ea5ce19c0dddef11e19d036f622460e..bab755eb824c8d92f0ed1e91d815eaf6a9df1fb4 100644 --- a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py +++ b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py @@ -4,13 +4,22 @@ from orchestrator import begin, workflow from orchestrator.forms import SubmitFormPage from orchestrator.targets import Target from orchestrator.types import SubscriptionLifecycle -from orchestrator.workflow import StepList, done +from orchestrator.workflow import StepList, conditional, done from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form +from pydantic import Field from pydantic_forms.types import FormGenerator, UUIDstr +from pydantic_forms.validators import Label from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import lso_interaction from gso.utils.types.tt_number import TTNumber +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + generate_fqdn_list, + terminate_l2circuit_dry, + terminate_l2circuit_real, +) def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -19,8 +28,11 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator: class TerminateForm(SubmitFormPage): tt_number: TTNumber - yield TerminateForm - return {"subscription": layer_2_circuit} + label: Label = Field("Should this workflow run Ansible playbooks to remove configuration from routers?") + run_ansible_steps: bool = True + + user_input = yield TerminateForm + return {"subscription": layer_2_circuit} | user_input.model_dump() @workflow( @@ -30,10 +42,16 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) def terminate_layer_2_circuit() -> StepList: """Terminate a Layer 2 Circuit subscription.""" + run_ansible_steps = conditional(lambda state: state["run_ansible_steps"]) + return ( begin >> store_process_subscription(Target.TERMINATE) >> unsync + >> run_ansible_steps(generate_fqdn_list) + >> run_ansible_steps(extract_partner_name_from_edge_port) + >> run_ansible_steps(lso_interaction(terminate_l2circuit_dry)) + >> run_ansible_steps(lso_interaction(terminate_l2circuit_real)) >> set_status(SubscriptionLifecycle.TERMINATED) >> resync >> done diff --git a/pyproject.toml b/pyproject.toml index 6fb91172edfe7f78200037184ba51e1f77cad78f..34e477ffd71d29f1bd31b5d2ea0282e99d0ed05a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ ignore = [ "PLR0913", "PLR0904", "PLW1514", + "S311", ] select = [ "A", diff --git a/test/cli/test_imports.py b/test/cli/test_imports.py index bc882410526e73dde39025e5da0adb91bd8881a5..26ab3bdd918da528e4c2f1564a8ced089caf803c 100644 --- a/test/cli/test_imports.py +++ b/test/cli/test_imports.py @@ -405,11 +405,12 @@ def l3_core_service_data(temp_file, faker, partner_factory, edge_port_subscripti @pytest.fixture() def layer_2_circuit_data(temp_file, faker, partner_factory, edge_port_subscription_factory): def _layer_2_circuit_data(**kwargs): + circuit_type = Layer2CircuitType.VLAN layer_2_circuit_input_data = { "partner": partner_factory()["name"], "service_type": Layer2CircuitServiceType.GEANT_PLUS, "gs_id": faker.imported_gs_id(), - "vc_id": generate_unique_vc_id(), + "vc_id": generate_unique_vc_id(circuit_type), "layer_2_circuit_side_a": { "edge_port": str(edge_port_subscription_factory().subscription_id), "vlan_id": faker.vlan_id(), @@ -418,7 +419,7 @@ def layer_2_circuit_data(temp_file, faker, partner_factory, edge_port_subscripti "edge_port": str(edge_port_subscription_factory().subscription_id), "vlan_id": faker.vlan_id(), }, - "layer_2_circuit_type": Layer2CircuitType.VLAN, + "layer_2_circuit_type": circuit_type, "vlan_range_lower_bound": faker.vlan_id(), "vlan_range_upper_bound": faker.vlan_id(), "policer_enabled": False, diff --git a/test/fixtures/edge_port_fixtures.py b/test/fixtures/edge_port_fixtures.py index ad6da994a083b5746107d381d831de6dc1a291ff..7ec1e0d9357e1e6174116c409f4e400a4ad78b95 100644 --- a/test/fixtures/edge_port_fixtures.py +++ b/test/fixtures/edge_port_fixtures.py @@ -16,7 +16,7 @@ from gso.utils.types.interfaces import PhysicalPortCapacity @pytest.fixture() -def edge_port_subscription_factory(faker, partner_factory, router_subscription_factory): +def edge_port_subscription_factory(faker, geant_partner, router_subscription_factory): def subscription_create( description=None, partner: dict | None = None, @@ -38,7 +38,8 @@ def edge_port_subscription_factory(faker, partner_factory, router_subscription_f ignore_if_down=False, is_imported=False, ) -> SubscriptionModel: - partner = partner or partner_factory() + # Use default partner if none defined + partner = partner or geant_partner node = node or router_subscription_factory(vendor=Vendor.NOKIA).router if is_imported: diff --git a/test/fixtures/l3_core_service_fixtures.py b/test/fixtures/l3_core_service_fixtures.py index bffe03dae1e11dfe8e06e5b2cd4bd02543cc4627..fc72ca3998ccec8c03e893f4178c452fbabf6e6f 100644 --- a/test/fixtures/l3_core_service_fixtures.py +++ b/test/fixtures/l3_core_service_fixtures.py @@ -133,7 +133,7 @@ def access_port_factory(faker, service_binding_port_factory): ): return AccessPort.new( subscription_id=uuid4(), - ap_type=ap_type or random.choice(list(APType)), # noqa: S311 + ap_type=ap_type or random.choice(list(APType)), sbp=service_binding_port or service_binding_port_factory(edge_port=edge_port, partner=partner), ) diff --git a/test/fixtures/layer_2_circuit_fixtures.py b/test/fixtures/layer_2_circuit_fixtures.py index f00b3375ce9da225e07e0259991019aa137066fc..584df642c94b7f010f0d219fa95498a5aafc58e1 100644 --- a/test/fixtures/layer_2_circuit_fixtures.py +++ b/test/fixtures/layer_2_circuit_fixtures.py @@ -96,22 +96,16 @@ def layer_2_circuit_subscription_factory(faker, geant_partner, edge_port_subscri layer_2_circuit_sides.append(layer_2_circuit_side) subscription.layer_2_circuit.layer_2_circuit_sides = layer_2_circuit_sides - subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id() + subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id(layer_2_circuit_type) subscription.layer_2_circuit.layer_2_circuit_type = layer_2_circuit_type if layer_2_circuit_type == Layer2CircuitType.VLAN: subscription.layer_2_circuit.vlan_range_lower_bound = vlan_range_lower_bound or faker.vlan_id() subscription.layer_2_circuit.vlan_range_upper_bound = vlan_range_upper_bound or faker.vlan_id() - else: - subscription.layer_2_circuit.vlan_range_lower_bound = None - subscription.layer_2_circuit.vlan_range_upper_bound = None subscription.layer_2_circuit.policer_enabled = policer_enabled if policer_enabled: subscription.layer_2_circuit.bandwidth = policer_bandwidth or faker.bandwidth() subscription.layer_2_circuit.policer_burst_rate = policer_burst_rate or faker.bandwidth() - else: - subscription.layer_2_circuit.bandwidth = None - subscription.layer_2_circuit.policer_burst_rate = None subscription.description = description or ( f"{subscription.product.name} - {subscription.layer_2_circuit.virtual_circuit_id}" ) diff --git a/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py b/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py index 49e19bc7c693e3a1f9d5be14cbab6898a439f18b..84d894d98c25784406b86d032575991676acda74 100644 --- a/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py @@ -17,15 +17,16 @@ def test_create_imported_layer_2_circuit_success( edge_port_a = str(edge_port_subscription_factory(partner=partner).subscription_id) edge_port_b = str(edge_port_subscription_factory(partner=partner).subscription_id) policer_enabled = faker.boolean() + circuit_type = Layer2CircuitType.VLAN creation_form_input_data = [ { "service_type": layer_2_service_type, "partner": partner["name"], - "layer_2_circuit_type": Layer2CircuitType.VLAN, + "layer_2_circuit_type": circuit_type, "policer_enabled": policer_enabled, "vlan_range_lower_bound": faker.vlan_id(), "vlan_range_upper_bound": faker.vlan_id(), - "vc_id": generate_unique_vc_id(), + "vc_id": generate_unique_vc_id(circuit_type), "policer_bandwidth": faker.bandwidth() if policer_enabled else None, "policer_burst_rate": faker.bandwidth() if policer_enabled else None, "gs_id": faker.imported_gs_id(), diff --git a/test/workflows/l2_circuit/test_create_layer_2_circuit.py b/test/workflows/l2_circuit/test_create_layer_2_circuit.py index ecec066a438b591d14b138bf16a3be93a4b1735d..4d282f6f73f5f2df113a069c75a2f3126773c2d6 100644 --- a/test/workflows/l2_circuit/test_create_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_create_layer_2_circuit.py @@ -1,10 +1,12 @@ +from unittest.mock import patch + import pytest from orchestrator.types import SubscriptionLifecycle from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit from gso.services.subscriptions import get_product_id_by_name -from test.workflows import assert_complete, extract_state, run_workflow +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow def generate_layer_2_circuit_input( @@ -58,16 +60,23 @@ def layer_2_circuit_ethernet_input( @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_create_layer_2_circuit_success( + mock_lso_interaction, layer_2_circuit_service_type, layer_2_circuit_input, faker, partner_factory, ): - result, _, _ = run_workflow("create_layer_2_circuit", layer_2_circuit_input) + result, process_stat, step_log = run_workflow("create_layer_2_circuit", layer_2_circuit_input) + + for _ in range(2): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Layer2Circuit.from_subscription(state["subscription_id"]) + assert mock_lso_interaction.call_count == 2 assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.virtual_circuit_id is not None assert len(subscription.layer_2_circuit.layer_2_circuit_sides) == 2 @@ -104,16 +113,23 @@ def test_create_layer_2_circuit_success( @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_create_layer_2_circuit_with_ethernet_type( + mock_lso_interaction, layer_2_circuit_service_type, layer_2_circuit_ethernet_input, faker, partner_factory, ): - result, _, _ = run_workflow("create_layer_2_circuit", layer_2_circuit_ethernet_input) + result, process_stat, step_log = run_workflow("create_layer_2_circuit", layer_2_circuit_ethernet_input) + + for _ in range(2): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Layer2Circuit.from_subscription(state["subscription_id"]) + assert mock_lso_interaction.call_count == 2 assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.virtual_circuit_id is not None assert len(subscription.layer_2_circuit.layer_2_circuit_sides) == 2 diff --git a/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py new file mode 100644 index 0000000000000000000000000000000000000000..9c016cd28a5abbbd1a3d9cfd8572eeb78c5fedff --- /dev/null +++ b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +import pytest + +from gso.products.product_types.edge_port import EdgePort +from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit +from gso.products.product_types.router import Router +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow + + +@pytest.mark.workflow() +@pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) +@pytest.mark.parametrize("run_old_side_ansible", [False, True]) +@pytest.mark.parametrize("run_new_side_ansible", [False, True]) +@patch("gso.services.lso_client._send_request") +def test_migrate_layer_2_circuit( + mock_lso_interaction, + run_new_side_ansible, + run_old_side_ansible, + layer_2_circuit_service_type, + layer_2_circuit_subscription_factory, + edge_port_subscription_factory, + faker, +): + subscription = layer_2_circuit_subscription_factory(layer_2_circuit_service_type=layer_2_circuit_service_type) + side_b_router = Router.from_subscription( + subscription.layer_2_circuit.layer_2_circuit_sides[1].sbp.edge_port.node.owner_subscription_id + ) + old_edge_port = EdgePort.from_subscription( + subscription.layer_2_circuit.layer_2_circuit_sides[1].sbp.edge_port.owner_subscription_id + ) + new_edge_port = edge_port_subscription_factory(node=side_b_router.router) + + initial_layer_2_circuit_data = [ + {"subscription_id": subscription.subscription_id}, + { + "tt_number": faker.tt_number(), + "replace_side": old_edge_port.subscription_id, + "migrate_to_different_site": False, + "run_old_side_ansible": run_old_side_ansible, + "run_new_side_ansible": run_new_side_ansible, + }, + {"new_edge_port": new_edge_port.subscription_id}, + ] + + result, process_stat, step_log = run_workflow("migrate_layer_2_circuit", initial_layer_2_circuit_data) + + lso_step_count = 0 + lso_step_count += 2 if run_old_side_ansible else 0 + lso_step_count += 2 if run_new_side_ansible else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Layer2Circuit.from_subscription(subscription_id) + assert subscription.status == "active" + assert mock_lso_interaction.call_count == lso_step_count + + replaced_edge_port = subscription.layer_2_circuit.layer_2_circuit_sides[1].sbp.edge_port + assert replaced_edge_port.model_dump(exclude="edge_port_ae_members") == new_edge_port.edge_port.model_dump( + exclude="edge_port_ae_members" + ) + assert replaced_edge_port.edge_port_ae_members[0].model_dump( + exclude="owner_subscription_id" + ) == new_edge_port.edge_port.edge_port_ae_members[0].model_dump(exclude="owner_subscription_id") + assert replaced_edge_port.edge_port_ae_members[1].model_dump( + exclude="owner_subscription_id" + ) == new_edge_port.edge_port.edge_port_ae_members[1].model_dump(exclude="owner_subscription_id") diff --git a/test/workflows/l2_circuit/test_modify_layer_2_circuit.py b/test/workflows/l2_circuit/test_modify_layer_2_circuit.py index cc6f773c85c8cb3ececb9e86d240fa5fd5924c44..e4f4dcce33efeabe2dac11c54a41b014577ab0de 100644 --- a/test/workflows/l2_circuit/test_modify_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_modify_layer_2_circuit.py @@ -1,15 +1,21 @@ +from unittest.mock import patch + import pytest from orchestrator.types import SubscriptionLifecycle from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit -from test.workflows import assert_complete, extract_state, run_workflow +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) +@pytest.mark.parametrize("run_ansible_steps", [False, True]) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_modify_layer_2_circuit_change_policer_bandwidth( + mock_lso_interaction, layer_2_circuit_service_type, + run_ansible_steps, layer_2_circuit_subscription_factory, faker, partner_factory, @@ -22,6 +28,7 @@ def test_modify_layer_2_circuit_change_policer_bandwidth( "layer_2_circuit_type": Layer2CircuitType.VLAN, "policer_enabled": False, "custom_service_name": faker.sentence(), + "run_ansible_steps": run_ansible_steps, }, { "vlan_range_lower_bound": subscription.layer_2_circuit.vlan_range_lower_bound, @@ -32,9 +39,16 @@ def test_modify_layer_2_circuit_change_policer_bandwidth( "layer_2_circuit_side_b": {}, }, ] - result, _, _ = run_workflow("modify_layer_2_circuit", input_form_data) + result, process_stat, step_log = run_workflow("modify_layer_2_circuit", input_form_data) + + lso_step_count = 2 if run_ansible_steps else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + subscription = Layer2Circuit.from_subscription(str(subscription.subscription_id)) assert_complete(result) + assert mock_lso_interaction.call_count == lso_step_count assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.policer_enabled is False assert subscription.layer_2_circuit.bandwidth is None @@ -54,9 +68,13 @@ def test_modify_layer_2_circuit_change_policer_bandwidth( @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) +@pytest.mark.parametrize("run_ansible_steps", [False, True]) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_modify_layer_2_circuit_change_circuit_type( + mock_lso_interaction, layer_2_circuit_service_type, + run_ansible_steps, layer_2_circuit_subscription_factory, faker, partner_factory, @@ -67,6 +85,7 @@ def test_modify_layer_2_circuit_change_circuit_type( { "tt_number": faker.tt_number(), "layer_2_circuit_type": Layer2CircuitType.ETHERNET, + "run_ansible_steps": run_ansible_steps, }, { "vlan_range_lower_bound": None, @@ -76,10 +95,17 @@ def test_modify_layer_2_circuit_change_circuit_type( "layer_2_circuit_side_b": {"vlan_id": faker.vlan_id()}, }, ] - result, _, _ = run_workflow("modify_layer_2_circuit", input_form_data) + result, process_stat, step_log = run_workflow("modify_layer_2_circuit", input_form_data) + + lso_step_count = 2 if run_ansible_steps else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Layer2Circuit.from_subscription(state["subscription_id"]) + assert mock_lso_interaction.call_count == lso_step_count assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.vlan_range_lower_bound is None assert subscription.layer_2_circuit.vlan_range_upper_bound is None diff --git a/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py b/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py index 750c7b06c06984229940dca59bfaa0751a085d17..55deedededc9c3c43ef3ca56d6145508cbf7f5d4 100644 --- a/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py @@ -1,20 +1,36 @@ +from unittest.mock import patch + import pytest from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit -from test.workflows import assert_complete, extract_state, run_workflow +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow @pytest.mark.workflow() @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) -def test_terminate_layer_2_circuit(layer_2_circuit_service_type, layer_2_circuit_subscription_factory, faker): +@pytest.mark.parametrize("run_ansible_steps", [True, False]) +@patch("gso.services.lso_client._send_request") +def test_terminate_layer_2_circuit( + mock_lso_interaction, layer_2_circuit_service_type, run_ansible_steps, layer_2_circuit_subscription_factory, faker +): subscription_id = str( layer_2_circuit_subscription_factory(layer_2_circuit_service_type=layer_2_circuit_service_type).subscription_id ) - initialt_layer_2_circuit_data = [{"subscription_id": subscription_id}, {"tt_number": faker.tt_number()}] - result, _, _ = run_workflow("terminate_layer_2_circuit", initialt_layer_2_circuit_data) + initial_layer_2_circuit_data = [ + {"subscription_id": subscription_id}, + {"tt_number": faker.tt_number(), "run_ansible_steps": run_ansible_steps}, + ] + result, process_stat, step_log = run_workflow("terminate_layer_2_circuit", initial_layer_2_circuit_data) + + lso_step_count = 2 if run_ansible_steps else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription_id = state["subscription_id"] subscription = Layer2Circuit.from_subscription(subscription_id) assert subscription.status == "terminated" + assert mock_lso_interaction.call_count == lso_step_count