From 49a3182d14dda6aed99f9c6661d1da97bda5916a Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <ak@geant.org>
Date: Thu, 27 Mar 2025 09:33:59 +0000
Subject: [PATCH 01/13] Initial LSO step in create-l2-circuit

---
 .../l2_circuit/create_layer_2_circuit.py      | 45 ++++++++++++++++++-
 1 file changed, 43 insertions(+), 2 deletions(-)

diff --git a/gso/workflows/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py
index 5138aef1a..7bded0954 100644
--- a/gso/workflows/l2_circuit/create_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py
@@ -1,8 +1,14 @@
 """Workflow for creating a new Layer 2 Circuit."""
 
+from copy import deepcopy
+from re import sub
+import re
 from typing import Any, Self
 from uuid import uuid4
 
+from gso.products.product_types import layer_2_circuit
+from gso.products.product_types import edge_port
+from gso.services.lso_client import lso_interaction
 from orchestrator import step, workflow
 from orchestrator.forms import FormPage, SubmitFormPage
 from orchestrator.targets import Target
@@ -18,7 +24,8 @@ 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.partners import get_partner_by_name
+from gso.services.lso_client import LSOState, lso_interaction
+from gso.services.partners import get_partner_by_id, 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
 from gso.utils.shared_enums import SBPType
@@ -136,8 +143,40 @@ 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, "fqdn_list": fqdn_list}
+
+@step("Expand subscription dictionary")
+def extract_partner_name_from_edgeport(
+    subscription: dict[str, Any]
+) -> State:
+    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 Service Binding Ports."""
+    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,
+    }
 
-    return {"subscription": subscription}
 
 
 @workflow(
@@ -152,6 +191,8 @@ def create_layer_2_circuit() -> StepList:
         >> create_subscription
         >> store_process_subscription(Target.CREATE)
         >> initialize_subscription
+        >> extract_partner_name_from_edgeport
+        >> lso_interaction(provision_l2circuit_dry)
         >> set_status(SubscriptionLifecycle.ACTIVE)
         >> resync
         >> done
-- 
GitLab


From a543f373dfd2b8b196bbbcb2606a07c8ba555bdf Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Thu, 27 Mar 2025 13:19:26 +0100
Subject: [PATCH 02/13] Add Layer 2 Circuit migration workflow

---
 gso/translations/en-GB.json                   |   1 +
 gso/utils/helpers.py                          |  13 +-
 gso/workflows/__init__.py                     |   1 +
 .../l2_circuit/create_layer_2_circuit.py      |  21 +-
 .../l2_circuit/migrate_layer2_circuit.py      |  20 --
 .../l2_circuit/migrate_layer_2_circuit.py     | 222 ++++++++++++++++++
 6 files changed, 242 insertions(+), 36 deletions(-)
 delete mode 100644 gso/workflows/l2_circuit/migrate_layer2_circuit.py
 create mode 100644 gso/workflows/l2_circuit/migrate_layer_2_circuit.py

diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json
index b1a54748d..02ea8449f 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 15b9fcd42..95bb0f9c3 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
@@ -267,15 +267,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),
+        ),
     )
 
 
diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py
index 0f1abab71..e977c62c2 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/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py
index 7bded0954..5381afe15 100644
--- a/gso/workflows/l2_circuit/create_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py
@@ -1,14 +1,9 @@
 """Workflow for creating a new Layer 2 Circuit."""
 
 from copy import deepcopy
-from re import sub
-import re
 from typing import Any, Self
 from uuid import uuid4
 
-from gso.products.product_types import layer_2_circuit
-from gso.products.product_types import edge_port
-from gso.services.lso_client import lso_interaction
 from orchestrator import step, workflow
 from orchestrator.forms import FormPage, SubmitFormPage
 from orchestrator.targets import Target
@@ -147,13 +142,18 @@ def initialize_subscription(
 
     return {"subscription": subscription, "fqdn_list": fqdn_list}
 
+
 @step("Expand subscription dictionary")
-def extract_partner_name_from_edgeport(
-    subscription: dict[str, Any]
-) -> State:
+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
+        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}
 
@@ -178,7 +178,6 @@ def provision_l2circuit_dry(
     }
 
 
-
 @workflow(
     "Create Layer 2 Circuit Service",
     initial_input_form=wrap_create_initial_input_form(initial_input_generator),
@@ -191,7 +190,7 @@ def create_layer_2_circuit() -> StepList:
         >> create_subscription
         >> store_process_subscription(Target.CREATE)
         >> initialize_subscription
-        >> extract_partner_name_from_edgeport
+        >> extract_partner_name_from_edge_port
         >> lso_interaction(provision_l2circuit_dry)
         >> set_status(SubscriptionLifecycle.ACTIVE)
         >> resync
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 e7d4d9b29..000000000
--- 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 000000000..d8c56f982
--- /dev/null
+++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
@@ -0,0 +1,222 @@
+"""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.
+"""
+
+from typing import Any, 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.workflow import StepList, begin, 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 services.partners import get_partner_by_id
+from workflows.l2_circuit.create_layer_2_circuit import extract_partner_name_from_edge_port
+
+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, lso_interaction
+from gso.utils.helpers import (
+    active_edge_port_selector,
+)
+from gso.utils.types.tt_number import TTNumber
+
+
+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
+
+    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)
+
+    user_input = yield SelectNewEdgePortForm
+
+    return {
+        "tt_number": initial_user_input.tt_number,
+        "subscription": subscription,
+        "subscription_id": subscription_id,
+        "old_edge_port": initial_user_input.replace_side,
+        "new_edge_port": user_input.new_edge_port,
+    }
+
+
+@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("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
+
+    return {"subscription": subscription}
+
+
+@step("[DRY RUN] Remove old config")
+def remove_old_config_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 remove_old_config_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,
+    }
+
+
+@step("[DRY RUN] Deploy new config")
+def deploy_new_config_dry(
+    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
+) -> LSOState:
+    """Perform a dry run of deploying new configuration for 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("[FOR REAL] Deploy new config")
+def deploy_new_config_real(
+    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
+) -> LSOState:
+    """Deploy configuration for the new 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,
+    }
+
+
+@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."""
+    return (
+        begin
+        >> store_process_subscription(Target.MODIFY)
+        >> unsync
+        >> generate_fqdn_list
+        >> extract_partner_name_from_edge_port
+        >> lso_interaction(remove_old_config_dry)
+        >> lso_interaction(remove_old_config_real)
+        >> update_subscription_model
+        >> generate_fqdn_list
+        >> extract_partner_name_from_edge_port
+        >> lso_interaction(deploy_new_config_dry)
+        >> lso_interaction(deploy_new_config_real)
+        >> resync
+        >> done
+    )
-- 
GitLab


From 557c5158a86fd10f097823cbc130ca6261954517 Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <ak@geant.org>
Date: Fri, 28 Mar 2025 12:00:01 +0000
Subject: [PATCH 03/13] Update VC_ID generator

generates vc_id using different ranges for different l2c_type.
---
 gso/utils/helpers.py                          |  7 ++++--
 .../l2_circuit/create_layer_2_circuit.py      | 24 +++++++++++++++++--
 .../l2_circuit/migrate_layer_2_circuit.py     |  8 +++++--
 3 files changed, 33 insertions(+), 6 deletions(-)

diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index 95bb0f9c3..b643c209b 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -304,7 +304,7 @@ 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:
+def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | None:
     """Generate a unique 8-digit VC_ID starting with '11'.
 
     This function attempts to generate an 8-digit VC_ID beginning with '11',
@@ -320,7 +320,10 @@ 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 == "Ethernet":
+            return f"{random.randint(20001,29999)}"  # noqa: S311
+        else:
+            return f"{random.randint(6000,6999)}"  # noqa: S311
 
     for _ in range(max_attempts):
         vc_id = create_vc_id()
diff --git a/gso/workflows/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py
index 5381afe15..d96b6cec4 100644
--- a/gso/workflows/l2_circuit/create_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py
@@ -127,8 +127,8 @@ 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
@@ -162,7 +162,7 @@ def extract_partner_name_from_edge_port(subscription: dict[str, Any]) -> State:
 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 Service Binding Ports."""
+    """Perform a dry run of deploying L2circuit."""
     extra_vars = {
         "subscription": modified_subscription,
         "dry_run": True,
@@ -177,6 +177,25 @@ def provision_l2circuit_dry(
         "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 L2circuit."""
+    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,
+    }
+
 
 @workflow(
     "Create Layer 2 Circuit Service",
@@ -192,6 +211,7 @@ def create_layer_2_circuit() -> StepList:
         >> 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_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
index d8c56f982..096f89f83 100644
--- a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
@@ -31,14 +31,15 @@ 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 services.partners import get_partner_by_id
-from workflows.l2_circuit.create_layer_2_circuit import extract_partner_name_from_edge_port
+from gso.services.partners import get_partner_by_id
+from gso.workflows.l2_circuit.create_layer_2_circuit import extract_partner_name_from_edge_port
 
 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, lso_interaction
 from gso.utils.helpers import (
     active_edge_port_selector,
+    generate_unique_vc_id
 )
 from gso.utils.types.tt_number import TTNumber
 
@@ -114,6 +115,9 @@ def update_subscription_model(subscription: Layer2Circuit, old_edge_port: UUIDst
         new_edge_port
     ).edge_port
 
+    subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id(l2c_type=subscription.layer_2_circuit.layer_2_circuit_type)
+
+
     return {"subscription": subscription}
 
 
-- 
GitLab


From f792029cd1a96a8ca85bfa95df81afea87360699 Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <ak@geant.org>
Date: Fri, 28 Mar 2025 12:00:26 +0000
Subject: [PATCH 04/13] Add LSO steps to `terminate_layer_2_circuit`

---
 .../l2_circuit/terminate_layer_2_circuit.py   | 56 +++++++++++++++++++
 1 file changed, 56 insertions(+)

diff --git a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
index e8773a678..56f2e5442 100644
--- a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
@@ -10,6 +10,7 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form
 from pydantic_forms.types import FormGenerator, UUIDstr
 
 from gso.products.product_types.layer_2_circuit import Layer2Circuit
+from gso.services.lso_client import LSOState, lso_interaction
 from gso.utils.types.tt_number import TTNumber
 
 
@@ -23,6 +24,58 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     return {"subscription": layer_2_circuit}
 
 
+@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("[DRY RUN] Remove old config")
+def remove_old_config_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": 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 remove_old_config_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": 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,
+    }
+
+
 @workflow(
     "Terminate Layer 2 Circuit Service",
     initial_input_form=wrap_modify_initial_input_form(_input_form_generator),
@@ -34,6 +87,9 @@ def terminate_layer_2_circuit() -> StepList:
         begin
         >> store_process_subscription(Target.TERMINATE)
         >> unsync
+        >> generate_fqdn_list
+        >> lso_interaction(remove_old_config_dry)
+        >> lso_interaction(remove_old_config_real)
         >> set_status(SubscriptionLifecycle.TERMINATED)
         >> resync
         >> done
-- 
GitLab


From b77e86dc54827ade8986b7a394d27ffafb59abe7 Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <ak@geant.org>
Date: Tue, 1 Apr 2025 10:07:03 +0100
Subject: [PATCH 05/13] Update to `generate_unique_vc_id`

takes `l2c_type` as an argument and returns VC_ID from different ranges
for VLAN and Ethernet.
---
 gso/utils/helpers.py | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index b643c209b..96ba8b19a 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -305,13 +305,15 @@ def validate_edge_port_number_of_members_based_on_lacp(*, number_of_members: int
 
 
 def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | None:
-    """Generate a unique 8-digit VC_ID starting with '11'.
+    """Generate a unique 8-digit VC_ID.
 
-    This function attempts to generate an 8-digit VC_ID beginning with '11',
+    This function attempts to generate a VC_ID depending on the circuit type
+    ('Ethernet' and 'VLAN' types circuits get their IDs from different ranges), and
     checking its uniqueness before returning it. A maximum attempt limit is
     set to prevent infinite loops in case the ID space is saturated.
 
     Args:
+        l2c_type: type of l2circuit.
         max_attempts: The maximum number of attempts to generate a unique ID.
 
     Returns:
@@ -321,7 +323,7 @@ def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | Non
     def create_vc_id() -> str:
         """Generate an 8-digit VC_ID starting with '11'."""
         if l2c_type == "Ethernet":
-            return f"{random.randint(20001,29999)}"  # noqa: S311
+            return f"{random.randint(30001,39999)}"  # noqa: S311
         else:
             return f"{random.randint(6000,6999)}"  # noqa: S311
 
-- 
GitLab


From f98ed521788d76b37a4eb36d68e2550f53d463dd Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <ak@geant.org>
Date: Tue, 1 Apr 2025 10:13:48 +0100
Subject: [PATCH 06/13] Add l2circuit migration WF in DB

---
 ...e57284_add_l2circuit_migration_workflow.py | 39 +++++++++++++++++++
 1 file changed, 39 insertions(+)
 create mode 100644 gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py

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 000000000..0dfcd2cbe
--- /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"])
-- 
GitLab


From b8d96681586dbb5003005c35e809a13d57d2329f Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Tue, 1 Apr 2025 14:24:53 +0200
Subject: [PATCH 07/13] Add mocked LSO interactions to Layer 2 circuit workflow
 unit tests

---
 gso/utils/helpers.py                          |  8 +++----
 .../l2_circuit/create_layer_2_circuit.py      |  5 ++++-
 .../l2_circuit/migrate_layer_2_circuit.py     | 18 ++++++++-------
 .../l2_circuit/terminate_layer_2_circuit.py   | 17 +++++++++-----
 test/cli/test_imports.py                      |  5 +++--
 test/fixtures/layer_2_circuit_fixtures.py     |  8 +------
 .../test_create_imported_layer_2_circuit.py   |  5 +++--
 .../l2_circuit/test_create_layer_2_circuit.py | 22 ++++++++++++++++---
 .../test_terminate_layer_2_circuit.py         | 18 +++++++++++----
 9 files changed, 69 insertions(+), 37 deletions(-)

diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index 96ba8b19a..c3b477e28 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -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
@@ -322,10 +323,9 @@ def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | Non
 
     def create_vc_id() -> str:
         """Generate an 8-digit VC_ID starting with '11'."""
-        if l2c_type == "Ethernet":
-            return f"{random.randint(30001,39999)}"  # noqa: S311
-        else:
-            return f"{random.randint(6000,6999)}"  # noqa: S311
+        if l2c_type == Layer2CircuitType.ETHERNET:
+            return f"{random.randint(30001, 39999)}"  # noqa: S311
+        return f"{random.randint(6000, 6999)}"  # noqa: S311
 
     for _ in range(max_attempts):
         vc_id = create_vc_id()
diff --git a/gso/workflows/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py
index d96b6cec4..0dd6e275e 100644
--- a/gso/workflows/l2_circuit/create_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py
@@ -128,7 +128,9 @@ def initialize_subscription(
         layer_2_circuit_sides.append(layer2_circuit_side)
     subscription.layer_2_circuit.layer_2_circuit_sides = layer_2_circuit_sides
     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.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
@@ -177,6 +179,7 @@ def provision_l2circuit_dry(
         "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]
diff --git a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
index 096f89f83..97c2ab4e3 100644
--- a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
@@ -25,23 +25,21 @@ 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, 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.services.partners import get_partner_by_id
-from gso.workflows.l2_circuit.create_layer_2_circuit import extract_partner_name_from_edge_port
 
 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, lso_interaction
-from gso.utils.helpers import (
-    active_edge_port_selector,
-    generate_unique_vc_id
-)
+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.create_layer_2_circuit import extract_partner_name_from_edge_port
 
 
 def input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
@@ -76,7 +74,7 @@ def input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     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)
+        new_edge_port: active_edge_port_selector(partner_id=replace_side_partner.partner_id)  # type: ignore[valid-type]
 
     user_input = yield SelectNewEdgePortForm
 
@@ -115,8 +113,12 @@ def update_subscription_model(subscription: Layer2Circuit, old_edge_port: UUIDst
         new_edge_port
     ).edge_port
 
-    subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id(l2c_type=subscription.layer_2_circuit.layer_2_circuit_type)
+    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}
 
diff --git a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
index 56f2e5442..8f0a8e306 100644
--- a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
@@ -1,17 +1,20 @@
 """Workflow for terminating a Layer 2 Circuit."""
 
+from typing import Any
+
 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, done, step
 from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync
 from orchestrator.workflows.utils import wrap_modify_initial_input_form
-from pydantic_forms.types import FormGenerator, UUIDstr
+from pydantic_forms.types import FormGenerator, State, UUIDstr
 
 from gso.products.product_types.layer_2_circuit import Layer2Circuit
 from gso.services.lso_client import LSOState, lso_interaction
 from gso.utils.types.tt_number import TTNumber
+from gso.workflows.l2_circuit.create_layer_2_circuit import extract_partner_name_from_edge_port
 
 
 def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
@@ -20,8 +23,8 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     class TerminateForm(SubmitFormPage):
         tt_number: TTNumber
 
-    yield TerminateForm
-    return {"subscription": layer_2_circuit}
+    user_input = yield TerminateForm
+    return {"subscription": layer_2_circuit} | user_input.model_dump()
 
 
 @step("Generate FQDN list")
@@ -36,13 +39,14 @@ def generate_fqdn_list(subscription: Layer2Circuit) -> State:
         ]
     }
 
+
 @step("[DRY RUN] Remove old config")
 def remove_old_config_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": subscription,
+        "subscription": modified_subscription,
         "dry_run": True,
         "verb": "terminate",
         "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - "
@@ -62,7 +66,7 @@ def remove_old_config_real(
 ) -> LSOState:
     """Remove old configuration of a Layer 2 Circuit."""
     extra_vars = {
-        "subscription": subscription,
+        "subscription": modified_subscription,
         "dry_run": False,
         "verb": "terminate",
         "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - "
@@ -88,6 +92,7 @@ def terminate_layer_2_circuit() -> StepList:
         >> store_process_subscription(Target.TERMINATE)
         >> unsync
         >> generate_fqdn_list
+        >> extract_partner_name_from_edge_port
         >> lso_interaction(remove_old_config_dry)
         >> lso_interaction(remove_old_config_real)
         >> set_status(SubscriptionLifecycle.TERMINATED)
diff --git a/test/cli/test_imports.py b/test/cli/test_imports.py
index bc8824105..26ab3bdd9 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/layer_2_circuit_fixtures.py b/test/fixtures/layer_2_circuit_fixtures.py
index f00b3375c..584df642c 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 49e19bc7c..84d894d98 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 ecec066a4..4d282f6f7 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_terminate_layer_2_circuit.py b/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py
index 750c7b06c..2d5a26606 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,30 @@
+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):
+@patch("gso.services.lso_client._send_request")
+def test_terminate_layer_2_circuit(
+    mock_lso_interaction, layer_2_circuit_service_type, 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()}]
+    result, process_stat, step_log = run_workflow("terminate_layer_2_circuit", initial_layer_2_circuit_data)
+
+    for _ in range(2):
+        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 == 2
-- 
GitLab


From a226f8e38c11eb722936af14997db3952f4d167b Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Wed, 2 Apr 2025 16:37:32 +0200
Subject: [PATCH 08/13] Add unit test for layer 2 migration workflow

---
 test/fixtures/edge_port_fixtures.py           |  5 +-
 .../test_migrate_layer_2_circuit.py           | 62 +++++++++++++++++++
 2 files changed, 65 insertions(+), 2 deletions(-)
 create mode 100644 test/workflows/l2_circuit/test_migrate_layer_2_circuit.py

diff --git a/test/fixtures/edge_port_fixtures.py b/test/fixtures/edge_port_fixtures.py
index ad6da994a..7ec1e0d93 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/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 000000000..efc1c8495
--- /dev/null
+++ b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py
@@ -0,0 +1,62 @@
+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)
+@patch("gso.services.lso_client._send_request")
+def test_migrate_layer_2_circuit(
+    mock_lso_interaction,
+    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,
+        },
+        {"new_edge_port": new_edge_port.subscription_id},
+    ]
+
+    result, process_stat, step_log = run_workflow("migrate_layer_2_circuit", initial_layer_2_circuit_data)
+
+    for _ in range(4):
+        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 == 4
+
+    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")
-- 
GitLab


From caec907305a626235cacdd2f121b19eea300302a Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <ak@geant.org>
Date: Fri, 4 Apr 2025 10:57:41 +0100
Subject: [PATCH 09/13] Add LSO steps to `modify_layer_2_circuit`, and to the
 respective test file.

---
 .../l2_circuit/modify_layer_2_circuit.py      | 63 ++++++++++++++++++-
 .../l2_circuit/test_modify_layer_2_circuit.py | 22 ++++++-
 2 files changed, 81 insertions(+), 4 deletions(-)

diff --git a/gso/workflows/l2_circuit/modify_layer_2_circuit.py b/gso/workflows/l2_circuit/modify_layer_2_circuit.py
index 91c791359..5e3abb396 100644
--- a/gso/workflows/l2_circuit/modify_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/modify_layer_2_circuit.py
@@ -1,5 +1,7 @@
 """A modification workflow for a Layer 2 Circuit subscription."""
 
+from typing import Any
+
 from orchestrator import begin, done, workflow
 from orchestrator.forms import FormPage, SubmitFormPage
 from orchestrator.targets import Target
@@ -7,16 +9,18 @@ from orchestrator.workflow import StepList, 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
-from pydantic_forms.types import FormGenerator, UUIDstr
+from pydantic_forms.types import FormGenerator, State, UUIDstr
 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 LSOState, 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.create_layer_2_circuit import extract_partner_name_from_edge_port
 
 
 def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
@@ -120,6 +124,59 @@ def modify_layer_2_circuit_subscription(
     return {"subscription": subscription}
 
 
+@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("[DRY RUN] Deploy new config")
+def deploy_new_config_dry(
+    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
+) -> LSOState:
+    """Perform a dry run of deploying new configuration for 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("[FOR REAL] Deploy new config")
+def deploy_new_config_real(
+    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
+) -> LSOState:
+    """Deploy configuration for the new 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,
+    }
+
+
 @workflow(
     "Modify Layer 2 Circuit Service",
     initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
@@ -131,7 +188,11 @@ def modify_layer_2_circuit() -> StepList:
         begin
         >> store_process_subscription(Target.MODIFY)
         >> unsync
+        >> generate_fqdn_list
+        >> extract_partner_name_from_edge_port
         >> modify_layer_2_circuit_subscription
+        >> lso_interaction(deploy_new_config_dry)
+        >> lso_interaction(deploy_new_config_real)
         >> resync
         >> done
     )
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 cc6f773c8..da4dcb6c8 100644
--- a/test/workflows/l2_circuit/test_modify_layer_2_circuit.py
+++ b/test/workflows/l2_circuit/test_modify_layer_2_circuit.py
@@ -1,14 +1,18 @@
+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.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,
     layer_2_circuit_subscription_factory,
     faker,
@@ -32,9 +36,14 @@ 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)
+
+    for _ in range(2):
+        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 == 2
     assert subscription.status == SubscriptionLifecycle.ACTIVE
     assert subscription.layer_2_circuit.policer_enabled is False
     assert subscription.layer_2_circuit.bandwidth is None
@@ -55,7 +64,9 @@ def test_modify_layer_2_circuit_change_policer_bandwidth(
 
 @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_modify_layer_2_circuit_change_circuit_type(
+    mock_lso_interaction,
     layer_2_circuit_service_type,
     layer_2_circuit_subscription_factory,
     faker,
@@ -76,10 +87,15 @@ 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)
+
+    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.vlan_range_lower_bound is None
     assert subscription.layer_2_circuit.vlan_range_upper_bound is None
-- 
GitLab


From c605c556c1cdab2cd66657ed24968d68d72512e4 Mon Sep 17 00:00:00 2001
From: Aleksandr Kurbatov <aleksandr.kurbatov@geant.org>
Date: Fri, 4 Apr 2025 11:25:22 +0000
Subject: [PATCH 10/13] Apply 3 suggestion(s) to 2 file(s)

Co-authored-by: Karel van Klink <karel.vanklink@geant.org>
---
 gso/utils/helpers.py                                | 13 +++++++------
 gso/workflows/l2_circuit/migrate_layer_2_circuit.py |  6 +++---
 2 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index c3b477e28..a5c1bc9dc 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -308,10 +308,11 @@ def validate_edge_port_number_of_members_based_on_lacp(*, number_of_members: int
 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 a VC_ID depending on the circuit type
-    ('Ethernet' and 'VLAN' types circuits get their IDs from different ranges), and
-    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.
@@ -324,8 +325,8 @@ def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | Non
     def create_vc_id() -> str:
         """Generate an 8-digit VC_ID starting with '11'."""
         if l2c_type == Layer2CircuitType.ETHERNET:
-            return f"{random.randint(30001, 39999)}"  # noqa: S311
-        return f"{random.randint(6000, 6999)}"  # noqa: S311
+            return str(random.randint(30001, 39999))  # noqa: S311
+        return str(random.randint(6000, 6999))  # noqa: S311
 
     for _ in range(max_attempts):
         vc_id = create_vc_id()
diff --git a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
index 97c2ab4e3..c20e29da2 100644
--- a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
@@ -14,9 +14,9 @@ When an operator initiates the workflow, they are required to specify both the s
 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.
+!!! 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 Any, TypeAlias, cast
-- 
GitLab


From 505e169d4cb31128e20425f643930f1506f5f623 Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Fri, 4 Apr 2025 14:16:19 +0200
Subject: [PATCH 11/13] Move duplicate Layer 2 Circuit steps into common file

---
 .../l2_circuit/create_layer_2_circuit.py      |  65 +---------
 .../l2_circuit/migrate_layer_2_circuit.py     | 114 ++---------------
 .../l2_circuit/modify_layer_2_circuit.py      |  70 ++--------
 gso/workflows/l2_circuit/shared_steps.py      | 121 ++++++++++++++++++
 .../l2_circuit/terminate_layer_2_circuit.py   |  72 ++---------
 5 files changed, 163 insertions(+), 279 deletions(-)
 create mode 100644 gso/workflows/l2_circuit/shared_steps.py

diff --git a/gso/workflows/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py
index 0dd6e275e..ce70f215f 100644
--- a/gso/workflows/l2_circuit/create_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py
@@ -1,6 +1,5 @@
 """Workflow for creating a new Layer 2 Circuit."""
 
-from copy import deepcopy
 from typing import Any, Self
 from uuid import uuid4
 
@@ -19,14 +18,19 @@ 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 LSOState, lso_interaction
-from gso.services.partners import get_partner_by_id, get_partner_by_name
+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
 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:
@@ -145,61 +149,6 @@ def initialize_subscription(
     return {"subscription": subscription, "fqdn_list": fqdn_list}
 
 
-@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 L2circuit."""
-    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 L2circuit."""
-    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,
-    }
-
-
 @workflow(
     "Create Layer 2 Circuit Service",
     initial_input_form=wrap_create_initial_input_form(initial_input_generator),
diff --git a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
index c20e29da2..d6d4a8f8a 100644
--- a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
@@ -19,7 +19,7 @@ EdgePort with the destination EdgePort and applies the necessary configuration c
     migrate the service.
 """
 
-from typing import Any, TypeAlias, cast
+from typing import TypeAlias, cast
 
 from orchestrator import step, workflow
 from orchestrator.forms import FormPage, SubmitFormPage
@@ -35,11 +35,18 @@ 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 LSOState, lso_interaction
+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.create_layer_2_circuit import extract_partner_name_from_edge_port
+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:
@@ -87,19 +94,6 @@ def input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     }
 
 
-@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("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."""
@@ -123,86 +117,6 @@ def update_subscription_model(subscription: Layer2Circuit, old_edge_port: UUIDst
     return {"subscription": subscription}
 
 
-@step("[DRY RUN] Remove old config")
-def remove_old_config_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 remove_old_config_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,
-    }
-
-
-@step("[DRY RUN] Deploy new config")
-def deploy_new_config_dry(
-    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
-) -> LSOState:
-    """Perform a dry run of deploying new configuration for 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("[FOR REAL] Deploy new config")
-def deploy_new_config_real(
-    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
-) -> LSOState:
-    """Deploy configuration for the new 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,
-    }
-
-
 @workflow(
     "Migrate Layer 2 Circuit",
     initial_input_form=wrap_modify_initial_input_form(input_form_generator),
@@ -216,13 +130,13 @@ def migrate_layer_2_circuit() -> StepList:
         >> unsync
         >> generate_fqdn_list
         >> extract_partner_name_from_edge_port
-        >> lso_interaction(remove_old_config_dry)
-        >> lso_interaction(remove_old_config_real)
+        >> lso_interaction(terminate_l2circuit_dry)
+        >> lso_interaction(terminate_l2circuit_real)
         >> update_subscription_model
         >> generate_fqdn_list
         >> extract_partner_name_from_edge_port
-        >> lso_interaction(deploy_new_config_dry)
-        >> lso_interaction(deploy_new_config_real)
+        >> lso_interaction(provision_l2circuit_dry)
+        >> 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 5e3abb396..120ba474d 100644
--- a/gso/workflows/l2_circuit/modify_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/modify_layer_2_circuit.py
@@ -1,7 +1,5 @@
 """A modification workflow for a Layer 2 Circuit subscription."""
 
-from typing import Any
-
 from orchestrator import begin, done, workflow
 from orchestrator.forms import FormPage, SubmitFormPage
 from orchestrator.targets import Target
@@ -9,18 +7,23 @@ from orchestrator.workflow import StepList, 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
-from pydantic_forms.types import FormGenerator, State, UUIDstr
+from pydantic_forms.types import FormGenerator, UUIDstr
 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 LSOState, lso_interaction
+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.create_layer_2_circuit import extract_partner_name_from_edge_port
+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:
@@ -124,59 +127,6 @@ def modify_layer_2_circuit_subscription(
     return {"subscription": subscription}
 
 
-@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("[DRY RUN] Deploy new config")
-def deploy_new_config_dry(
-    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
-) -> LSOState:
-    """Perform a dry run of deploying new configuration for 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("[FOR REAL] Deploy new config")
-def deploy_new_config_real(
-    process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str]
-) -> LSOState:
-    """Deploy configuration for the new 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,
-    }
-
-
 @workflow(
     "Modify Layer 2 Circuit Service",
     initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
@@ -191,8 +141,8 @@ def modify_layer_2_circuit() -> StepList:
         >> generate_fqdn_list
         >> extract_partner_name_from_edge_port
         >> modify_layer_2_circuit_subscription
-        >> lso_interaction(deploy_new_config_dry)
-        >> lso_interaction(deploy_new_config_real)
+        >> lso_interaction(provision_l2circuit_dry)
+        >> 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 000000000..48b6429b6
--- /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 8f0a8e306..223523b55 100644
--- a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
@@ -1,20 +1,23 @@
 """Workflow for terminating a Layer 2 Circuit."""
 
-from typing import Any
-
 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, step
+from orchestrator.workflow import StepList, 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_forms.types import FormGenerator, State, UUIDstr
+from pydantic_forms.types import FormGenerator, UUIDstr
 
 from gso.products.product_types.layer_2_circuit import Layer2Circuit
-from gso.services.lso_client import LSOState, lso_interaction
+from gso.services.lso_client import lso_interaction
 from gso.utils.types.tt_number import TTNumber
-from gso.workflows.l2_circuit.create_layer_2_circuit import extract_partner_name_from_edge_port
+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:
@@ -27,59 +30,6 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     return {"subscription": layer_2_circuit} | user_input.model_dump()
 
 
-@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("[DRY RUN] Remove old config")
-def remove_old_config_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 remove_old_config_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,
-    }
-
-
 @workflow(
     "Terminate Layer 2 Circuit Service",
     initial_input_form=wrap_modify_initial_input_form(_input_form_generator),
@@ -93,8 +43,8 @@ def terminate_layer_2_circuit() -> StepList:
         >> unsync
         >> generate_fqdn_list
         >> extract_partner_name_from_edge_port
-        >> lso_interaction(remove_old_config_dry)
-        >> lso_interaction(remove_old_config_real)
+        >> lso_interaction(terminate_l2circuit_dry)
+        >> lso_interaction(terminate_l2circuit_real)
         >> set_status(SubscriptionLifecycle.TERMINATED)
         >> resync
         >> done
-- 
GitLab


From a456a1ec263cbab3383725707223a75d5ae10dee Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Fri, 4 Apr 2025 14:44:13 +0200
Subject: [PATCH 12/13] Add options to skip ansible playbook runs in layer 2
 circuit workflows

---
 .../l2_circuit/migrate_layer_2_circuit.py     | 28 +++++++++++++------
 .../l2_circuit/modify_layer_2_circuit.py      | 15 ++++++----
 .../l2_circuit/terminate_layer_2_circuit.py   | 17 +++++++----
 .../test_migrate_layer_2_circuit.py           | 14 ++++++++--
 .../l2_circuit/test_modify_layer_2_circuit.py | 18 +++++++++---
 .../test_terminate_layer_2_circuit.py         | 14 +++++++---
 6 files changed, 77 insertions(+), 29 deletions(-)

diff --git a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
index d6d4a8f8a..70a91e5aa 100644
--- a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py
@@ -26,7 +26,7 @@ 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, done
+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
@@ -75,6 +75,11 @@ def input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
         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)
 
@@ -87,6 +92,8 @@ def input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
 
     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,
@@ -124,19 +131,22 @@ def update_subscription_model(subscription: Layer2Circuit, old_edge_port: UUIDst
 )
 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
-        >> generate_fqdn_list
-        >> extract_partner_name_from_edge_port
-        >> lso_interaction(terminate_l2circuit_dry)
-        >> lso_interaction(terminate_l2circuit_real)
+        >> 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
-        >> generate_fqdn_list
-        >> extract_partner_name_from_edge_port
-        >> lso_interaction(provision_l2circuit_dry)
-        >> lso_interaction(provision_l2circuit_real)
+        >> 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 120ba474d..ae879037c 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
@@ -43,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):
@@ -134,15 +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
-        >> generate_fqdn_list
-        >> extract_partner_name_from_edge_port
+        >> run_ansible_steps(generate_fqdn_list)
+        >> run_ansible_steps(extract_partner_name_from_edge_port)
         >> modify_layer_2_circuit_subscription
-        >> lso_interaction(provision_l2circuit_dry)
-        >> lso_interaction(provision_l2circuit_real)
+        >> 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/terminate_layer_2_circuit.py b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
index 223523b55..bab755eb8 100644
--- a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
+++ b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py
@@ -4,10 +4,12 @@ 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
@@ -26,6 +28,9 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     class TerminateForm(SubmitFormPage):
         tt_number: TTNumber
 
+        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()
 
@@ -37,14 +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
-        >> generate_fqdn_list
-        >> extract_partner_name_from_edge_port
-        >> lso_interaction(terminate_l2circuit_dry)
-        >> lso_interaction(terminate_l2circuit_real)
+        >> 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/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py
index efc1c8495..9c016cd28 100644
--- a/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py
+++ b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py
@@ -10,9 +10,13 @@ from test.workflows import assert_complete, assert_lso_interaction_success, extr
 
 @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,
@@ -33,13 +37,19 @@ def test_migrate_layer_2_circuit(
             "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)
 
-    for _ in range(4):
+    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)
@@ -48,7 +58,7 @@ def test_migrate_layer_2_circuit(
     subscription_id = state["subscription_id"]
     subscription = Layer2Circuit.from_subscription(subscription_id)
     assert subscription.status == "active"
-    assert mock_lso_interaction.call_count == 4
+    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(
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 da4dcb6c8..e4f4dcce3 100644
--- a/test/workflows/l2_circuit/test_modify_layer_2_circuit.py
+++ b/test/workflows/l2_circuit/test_modify_layer_2_circuit.py
@@ -9,11 +9,13 @@ from test.workflows import assert_complete, assert_lso_interaction_success, extr
 
 
 @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,
@@ -26,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,
@@ -38,12 +41,14 @@ def test_modify_layer_2_circuit_change_policer_bandwidth(
     ]
     result, process_stat, step_log = run_workflow("modify_layer_2_circuit", input_form_data)
 
-    for _ in range(2):
+    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 == 2
+    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
@@ -63,11 +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,
@@ -78,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,
@@ -89,13 +97,15 @@ def test_modify_layer_2_circuit_change_circuit_type(
     ]
     result, process_stat, step_log = run_workflow("modify_layer_2_circuit", input_form_data)
 
-    for _ in range(2):
+    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 == 2
+    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 2d5a26606..55deedede 100644
--- a/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py
+++ b/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py
@@ -8,17 +8,23 @@ from test.workflows import assert_complete, assert_lso_interaction_success, extr
 
 @pytest.mark.workflow()
 @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES)
+@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, layer_2_circuit_subscription_factory, faker
+    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
     )
-    initial_layer_2_circuit_data = [{"subscription_id": subscription_id}, {"tt_number": faker.tt_number()}]
+    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)
 
-    for _ in range(2):
+    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)
@@ -27,4 +33,4 @@ def test_terminate_layer_2_circuit(
     subscription_id = state["subscription_id"]
     subscription = Layer2Circuit.from_subscription(subscription_id)
     assert subscription.status == "terminated"
-    assert mock_lso_interaction.call_count == 2
+    assert mock_lso_interaction.call_count == lso_step_count
-- 
GitLab


From f692b202471701612705f2e4eb56516706f0bf49 Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Tue, 8 Apr 2025 09:45:33 +0200
Subject: [PATCH 13/13] Remove rule S311 from linting

---
 gso/utils/helpers.py                         | 4 ++--
 gso/workflows/edge_port/migrate_edge_port.py | 4 ++--
 pyproject.toml                               | 1 +
 test/fixtures/l3_core_service_fixtures.py    | 2 +-
 4 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index a5c1bc9dc..c20e8a4e7 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -325,8 +325,8 @@ def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | Non
     def create_vc_id() -> str:
         """Generate an 8-digit VC_ID starting with '11'."""
         if l2c_type == Layer2CircuitType.ETHERNET:
-            return str(random.randint(30001, 39999))  # noqa: S311
-        return str(random.randint(6000, 6999))  # noqa: S311
+            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/edge_port/migrate_edge_port.py b/gso/workflows/edge_port/migrate_edge_port.py
index afd2d6d0f..ecdabd6f3 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/pyproject.toml b/pyproject.toml
index 6fb91172e..34e477ffd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ ignore = [
     "PLR0913",
     "PLR0904",
     "PLW1514",
+    "S311",
 ]
 select = [
     "A",
diff --git a/test/fixtures/l3_core_service_fixtures.py b/test/fixtures/l3_core_service_fixtures.py
index bffe03dae..fc72ca399 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),
         )
 
-- 
GitLab