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