diff --git a/gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py b/gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..0dfcd2cbee3f627b93c2d1d38247cd3a2ca72699 --- /dev/null +++ b/gso/migrations/versions/2025-03-27_3541c7e57284_add_l2circuit_migration_workflow.py @@ -0,0 +1,39 @@ +"""Add L2Circuit migration workflow. + +Revision ID: 3541c7e57284 +Revises: b14f71db2b58 +Create Date: 2025-03-27 15:35:39.351436 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '3541c7e57284' +down_revision = 'b14f71db2b58' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import create_workflow, delete_workflow + +new_workflows = [ + { + "name": "migrate_layer_2_circuit", + "target": "MODIFY", + "description": "Migrate Layer 2 Circuit", + "product_type": "Layer2Circuit" + } +] + + +def upgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + create_workflow(conn, workflow) + + +def downgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + delete_workflow(conn, workflow["name"]) diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json index b1a54748dbd43be1fc98ca3145b467e097f9f14a..02ea8449fd9eefba694548940ab8b83a6a61a85d 100644 --- a/gso/translations/en-GB.json +++ b/gso/translations/en-GB.json @@ -114,6 +114,7 @@ "import_switch": "NOT FOR HUMANS -- Finalize import into a Switch", "migrate_edge_port": "Migrate Edge Port", "migrate_iptrunk": "Migrate IP Trunk", + "migrate_layer_2_circuit": "Migrate Layer 2 Circuit", "migrate_l3_core_service": "Migrate L3 Core Service", "modify_connection_strategy": "Modify connection strategy", "modify_edge_port": "Modify Edge Port", diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index 15b9fcd42b193127797d06f7e771519efc3ff191..c20e8a4e780daff69688fa1b83b7cf57787ce99a 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -3,7 +3,7 @@ import random import re from ipaddress import IPv4Network, IPv6Network -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias, cast from uuid import UUID from orchestrator.types import SubscriptionLifecycle @@ -11,6 +11,7 @@ from pydantic_forms.types import UUIDstr from pydantic_forms.validators import Choice from gso import settings +from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import Router from gso.services.netbox_client import NetboxClient @@ -267,15 +268,18 @@ def active_switch_selector() -> Choice: return Choice("Select a switch", zip(switch_subscriptions.keys(), switch_subscriptions.items(), strict=True)) # type: ignore[arg-type] -def active_edge_port_selector(*, partner_id: UUIDstr | None = None) -> Choice: +def active_edge_port_selector(*, partner_id: UUIDstr | None = None) -> TypeAlias: """Generate a dropdown selector for choosing an active Edge Port in an input form.""" edge_ports = get_active_edge_port_subscriptions(partner_id=partner_id) options = {str(edge_port.subscription_id): edge_port.description for edge_port in edge_ports} - return Choice( - "Select an Edge Port", - zip(options.keys(), options.items(), strict=True), # type: ignore[arg-type] + return cast( + type[Choice], + Choice.__call__( + "Select an Edge Port", + zip(options.keys(), options.items(), strict=True), + ), ) @@ -301,14 +305,17 @@ def validate_edge_port_number_of_members_based_on_lacp(*, number_of_members: int raise ValueError(err_msg) -def generate_unique_vc_id(max_attempts: int = 100) -> VC_ID | None: - """Generate a unique 8-digit VC_ID starting with '11'. +def generate_unique_vc_id(l2c_type: str, max_attempts: int = 100) -> VC_ID | None: + """Generate a unique 8-digit VC_ID. - This function attempts to generate an 8-digit VC_ID beginning with '11', - checking its uniqueness before returning it. A maximum attempt limit is - set to prevent infinite loops in case the ID space is saturated. + This function attempts to generate a ``VC_ID`` based on their circuit type, + and ensures uniqueness before returning it. A maximum attempt limit is + set to prevent an infinite loop in case the ID space is saturated. + The range used for generating a ``VC_ID`` depends on the circuit type: + ``Ethernet`` and ``VLAN`` type circuits get their IDs from different ranges. Args: + l2c_type: type of l2circuit. max_attempts: The maximum number of attempts to generate a unique ID. Returns: @@ -317,7 +324,9 @@ def generate_unique_vc_id(max_attempts: int = 100) -> VC_ID | None: def create_vc_id() -> str: """Generate an 8-digit VC_ID starting with '11'.""" - return f"11{random.randint(100000, 999999)}" # noqa: S311 + if l2c_type == Layer2CircuitType.ETHERNET: + return str(random.randint(30001, 39999)) + return str(random.randint(6000, 6999)) for _ in range(max_attempts): vc_id = create_vc_id() diff --git a/gso/utils/workflow_steps.py b/gso/utils/workflow_steps.py index 6c90f051cb19b312a1706db411a6084b5be1b5fe..50f754a96a6b8ba263ce24ee49ae67d5f1f125f6 100644 --- a/gso/utils/workflow_steps.py +++ b/gso/utils/workflow_steps.py @@ -50,11 +50,7 @@ def _deploy_base_config( def _update_sdp_mesh( - subscription: dict[str, Any], - tt_number: str, - process_id: UUIDstr, - *, - dry_run: bool, + subscription: dict[str, Any], tt_number: str, process_id: UUIDstr, *, dry_run: bool, verb: str ) -> LSOState: inventory = generate_inventory_for_routers( router_role=RouterRole.PE, router_vendor=Vendor.NOKIA, exclude_routers=[subscription["router"]["router_fqdn"]] @@ -65,7 +61,7 @@ def _update_sdp_mesh( "subscription": subscription, "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " f"Update the SDP mesh for L2circuits(epipes) config on PE NOKIA routers", - "verb": "update_sdp_mesh", + "verb": verb, "pe_router_list": { subscription["router"]["router_fqdn"]: { "lo4": str(subscription["router"]["router_lo_ipv4_address"]), @@ -93,7 +89,7 @@ def _update_sdp_single_pe( "subscription": subscription, "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " f"Update the SDP mesh for L2circuits(epipes) config on PE NOKIA routers", - "verb": "update_sdp_mesh", + "verb": "add_pe_to_sdp_mesh", "pe_router_list": generate_inventory_for_routers( router_role=RouterRole.PE, exclude_routers=[subscription["router"]["router_fqdn"]], @@ -288,15 +284,27 @@ def add_pe_mesh_to_pe_real(subscription: dict[str, Any], tt_number: str, process @step("[DRY RUN] Include the PE into SDP mesh on other Nokia PEs") -def update_sdp_mesh_dry(subscription: dict[str, Any], tt_number: str, process_id: UUIDstr) -> State: +def add_pe_to_sdp_mesh_dry(subscription: dict[str, Any], tt_number: str, process_id: UUIDstr) -> State: + """Perform a dry run of including new PE router in SDP mesh on other NOKIA PE routers.""" + return _update_sdp_mesh(subscription, tt_number, process_id, dry_run=True, verb="add_pe_to_sdp_mesh") + + +@step("[FOR REAL] Include the PE into SDP mesh on other Nokia PEs") +def add_pe_to_sdp_mesh_real(subscription: dict[str, Any], tt_number: str, process_id: UUIDstr) -> State: + """Perform a real run of including new PE router in SDP mesh on other NOKIA PE routers.""" + return _update_sdp_mesh(subscription, tt_number, process_id, dry_run=False, verb="add_pe_to_sdp_mesh") + + +@step("[DRY RUN] Include the PE into SDP mesh on other Nokia PEs") +def remove_pe_from_sdp_mesh_dry(subscription: dict[str, Any], tt_number: str, process_id: UUIDstr) -> State: """Perform a dry run of including new PE router in SDP mesh on other NOKIA PE routers.""" - return _update_sdp_mesh(subscription, tt_number, process_id, dry_run=True) + return _update_sdp_mesh(subscription, tt_number, process_id, dry_run=True, verb="remove_pe_from_sdp_mesh") @step("[FOR REAL] Include the PE into SDP mesh on other Nokia PEs") -def update_sdp_mesh_real(subscription: dict[str, Any], tt_number: str, process_id: UUIDstr) -> State: +def remove_pe_from_sdp_mesh_real(subscription: dict[str, Any], tt_number: str, process_id: UUIDstr) -> State: """Perform a real run of including new PE router in SDP mesh on other NOKIA PE routers.""" - return _update_sdp_mesh(subscription, tt_number, process_id, dry_run=False) + return _update_sdp_mesh(subscription, tt_number, process_id, dry_run=False, verb="remove_pe_from_sdp_mesh") @step("[DRY RUN] Configure SDP on the PE to all other Nokia PEs") diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py index 0f1abab7165f2db22138765e51591705cf04ae55..e977c62c25e434852c999d91cd0b2239a3da3e6b 100644 --- a/gso/workflows/__init__.py +++ b/gso/workflows/__init__.py @@ -132,6 +132,7 @@ LazyWorkflowInstance("gso.workflows.l3_core_service.validate_prefix_list", "vali LazyWorkflowInstance("gso.workflows.l2_circuit.create_layer_2_circuit", "create_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.modify_layer_2_circuit", "modify_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.terminate_layer_2_circuit", "terminate_layer_2_circuit") +LazyWorkflowInstance("gso.workflows.l2_circuit.migrate_layer_2_circuit", "migrate_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.create_imported_layer_2_circuit", "create_imported_layer_2_circuit") LazyWorkflowInstance("gso.workflows.l2_circuit.import_layer_2_circuit", "import_layer_2_circuit") diff --git a/gso/workflows/edge_port/migrate_edge_port.py b/gso/workflows/edge_port/migrate_edge_port.py index afd2d6d0f46e9dd6a81f9fe1e290af0ff3b217d0..ecdabd6f3460343249e98b59bd87557514ebdeb5 100644 --- a/gso/workflows/edge_port/migrate_edge_port.py +++ b/gso/workflows/edge_port/migrate_edge_port.py @@ -292,7 +292,7 @@ def migrate_l3_core_services_to_new_node(subscription_id: UUIDstr, tt_number: TT }, ], ], - countdown=random.choice([2, 3, 4, 5]), # noqa: S311 + countdown=random.choice([2, 3, 4, 5]), ) return {"l3_core_services": l3_core_services} @@ -319,7 +319,7 @@ def migrate_l2_circuits_to_new_node(subscription_id: UUIDstr, tt_number: TTNumbe }, ], ], - countdown=random.choice([2, 3, 4, 5]), # noqa: S311 + countdown=random.choice([2, 3, 4, 5]), ) return {"layer2_circuits": layer2_circuits} diff --git a/gso/workflows/l2_circuit/create_layer_2_circuit.py b/gso/workflows/l2_circuit/create_layer_2_circuit.py index 5138aef1a167fe90a39c96bc0abaf557aa9ec875..ce70f215fa60ee80cc9a7a4f0240bec8729a370e 100644 --- a/gso/workflows/l2_circuit/create_layer_2_circuit.py +++ b/gso/workflows/l2_circuit/create_layer_2_circuit.py @@ -18,6 +18,7 @@ from gso.products.product_blocks.layer_2_circuit import Layer2CircuitSideBlockIn from gso.products.product_blocks.service_binding_port import ServiceBindingPortInactive from gso.products.product_types.edge_port import EdgePort from gso.products.product_types.layer_2_circuit import Layer2Circuit, Layer2CircuitInactive +from gso.services.lso_client import lso_interaction from gso.services.partners import get_partner_by_name from gso.services.subscriptions import generate_unique_id from gso.utils.helpers import active_edge_port_selector, generate_unique_vc_id, partner_choice @@ -25,6 +26,11 @@ from gso.utils.shared_enums import SBPType from gso.utils.types.interfaces import BandwidthString from gso.utils.types.tt_number import TTNumber from gso.utils.types.virtual_identifiers import VLAN_ID +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + provision_l2circuit_dry, + provision_l2circuit_real, +) def initial_input_generator(product_name: str) -> FormGenerator: @@ -125,8 +131,10 @@ def initialize_subscription( layer2_circuit_side = Layer2CircuitSideBlockInactive.new(uuid4(), sbp=sbp) layer_2_circuit_sides.append(layer2_circuit_side) subscription.layer_2_circuit.layer_2_circuit_sides = layer_2_circuit_sides - subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id() subscription.layer_2_circuit.layer_2_circuit_type = layer_2_circuit_type + subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id( + l2c_type=subscription.layer_2_circuit.layer_2_circuit_type + ) subscription.layer_2_circuit.vlan_range_lower_bound = vlan_range_lower_bound subscription.layer_2_circuit.vlan_range_upper_bound = vlan_range_upper_bound subscription.layer_2_circuit.policer_enabled = policer_enabled @@ -136,8 +144,9 @@ def initialize_subscription( subscription.description = f"{subscription.product.name} - {subscription.layer_2_circuit.virtual_circuit_id}" subscription = Layer2Circuit.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) + fqdn_list = [side.sbp.edge_port.node.router_fqdn for side in subscription.layer_2_circuit.layer_2_circuit_sides] - return {"subscription": subscription} + return {"subscription": subscription, "fqdn_list": fqdn_list} @workflow( @@ -152,6 +161,9 @@ def create_layer_2_circuit() -> StepList: >> create_subscription >> store_process_subscription(Target.CREATE) >> initialize_subscription + >> extract_partner_name_from_edge_port + >> lso_interaction(provision_l2circuit_dry) + >> lso_interaction(provision_l2circuit_real) >> set_status(SubscriptionLifecycle.ACTIVE) >> resync >> done diff --git a/gso/workflows/l2_circuit/migrate_layer2_circuit.py b/gso/workflows/l2_circuit/migrate_layer2_circuit.py deleted file mode 100644 index e7d4d9b294cce4bd0ab5d8b2b0926fa28567a967..0000000000000000000000000000000000000000 --- a/gso/workflows/l2_circuit/migrate_layer2_circuit.py +++ /dev/null @@ -1,20 +0,0 @@ -"""This workflow migrates an L2 Core Service to a new Edge Port. - -It can be triggered by an operator or automatically by the system during Edge Port migration which is a separate -workflow. - -System-triggered migration: -When the system migrates an Edge Port, it runs the workflow automatically. The source and destination Edge Ports are -set to the same values. Then here migration only applies the configuration to the router and fill the drift between -core DB as source of truth and the actual network since the intent of network has changed in the previous workflow -even though the L2 Circuit Service is not changed. - -Operator-triggered migration: -When an operator initiates the workflow, they are required to specify both the source and destination EdgePorts. -During the migration process, the system updates the related edge_port reference to replace the source -EdgePort with the destination EdgePort and applies the necessary configuration changes to the router. - -Important Note: -Since an L2 Circuit Service has multiple side, the workflow must be run separately for each side to fully -migrate the service. -""" diff --git a/gso/workflows/l2_circuit/migrate_layer_2_circuit.py b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py new file mode 100644 index 0000000000000000000000000000000000000000..70a91e5aaaf24c274ca510bb9121e3b2ac37a882 --- /dev/null +++ b/gso/workflows/l2_circuit/migrate_layer_2_circuit.py @@ -0,0 +1,152 @@ +"""This workflow migrates an L2 Core Service to a new Edge Port. + +It can be triggered by an operator or automatically by the system during Edge Port migration which is a separate +workflow. + +System-triggered migration: +When the system migrates an Edge Port, it runs the workflow automatically. The source and destination Edge Ports are +set to the same values. Then here migration only applies the configuration to the router and fill the drift between +core DB as source of truth and the actual network since the intent of network has changed in the previous workflow +even though the L2 Circuit Service is not changed. + +Operator-triggered migration: +When an operator initiates the workflow, they are required to specify both the source and destination EdgePorts. +During the migration process, the system updates the related edge_port reference to replace the source +EdgePort with the destination EdgePort and applies the necessary configuration changes to the router. + +!!! info + Since an L2 Circuit Service has two sides, the workflow must be run separately for each side to fully + migrate the service. +""" + +from typing import TypeAlias, cast + +from orchestrator import step, workflow +from orchestrator.forms import FormPage, SubmitFormPage +from orchestrator.forms.validators import Choice, Label +from orchestrator.targets import Target +from orchestrator.utils.errors import ProcessFailureError +from orchestrator.workflow import StepList, begin, conditional, done +from orchestrator.workflows.steps import resync, store_process_subscription, unsync +from orchestrator.workflows.utils import wrap_modify_initial_input_form +from pydantic import ConfigDict, Field +from pydantic_forms.types import FormGenerator, State, UUIDstr +from pydantic_forms.validators import Divider + +from gso.products.product_types.edge_port import EdgePort +from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import lso_interaction +from gso.services.partners import get_partner_by_id +from gso.utils.helpers import active_edge_port_selector, generate_unique_vc_id +from gso.utils.types.tt_number import TTNumber +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + generate_fqdn_list, + provision_l2circuit_dry, + provision_l2circuit_real, + terminate_l2circuit_dry, + terminate_l2circuit_real, +) + + +def input_form_generator(subscription_id: UUIDstr) -> FormGenerator: + """Generate an input form for migrating a Layer 2 Circuit.""" + subscription = Layer2Circuit.from_subscription(subscription_id) + + def circuit_side_selector() -> TypeAlias: + sides_dict = { + str(side.sbp.edge_port.owner_subscription_id): EdgePort.from_subscription( + side.sbp.edge_port.owner_subscription_id + ).description + for side in subscription.layer_2_circuit.layer_2_circuit_sides + } + return cast( + type[Choice], + Choice.__call__("Select one side of the circuit", zip(sides_dict.keys(), sides_dict.items(), strict=True)), + ) + + class MigrateL2CircuitForm(FormPage): + model_config = ConfigDict(title="Migrating Layer 2 Circuit") + + tt_number: TTNumber + replace_side: circuit_side_selector() # type: ignore[valid-type] + divider: Divider = Field(None, exclude=True) + + label_a: Label = Field("Are we migrating to a different site?", exclude=True) + migrate_to_different_site: bool = False + + label_b: Label = Field("Execute Ansible playbooks on the OLD side of the circuit?", exclude=True) + run_old_side_ansible: bool = True + label_c: Label = Field("Execute Ansible playbooks on the NEW side of the circuit?", exclude=True) + run_new_side_ansible: bool = True + + initial_user_input = yield MigrateL2CircuitForm + replace_side_partner = get_partner_by_id(EdgePort.from_subscription(initial_user_input.replace_side).customer_id) + + class SelectNewEdgePortForm(SubmitFormPage): + model_config = ConfigDict(title="Migrating Layer 2 Circuit") + + new_edge_port: active_edge_port_selector(partner_id=replace_side_partner.partner_id) # type: ignore[valid-type] + + user_input = yield SelectNewEdgePortForm + + return { + "tt_number": initial_user_input.tt_number, + "run_old_side_ansible": initial_user_input.run_old_side_ansible, + "run_new_side_ansible": initial_user_input.run_new_side_ansible, + "subscription": subscription, + "subscription_id": subscription_id, + "old_edge_port": initial_user_input.replace_side, + "new_edge_port": user_input.new_edge_port, + } + + +@step("Update subscription model") +def update_subscription_model(subscription: Layer2Circuit, old_edge_port: UUIDstr, new_edge_port: UUIDstr) -> State: + """Replace the old Edge Port with the newly selected one in the subscription model.""" + replace_index = ( + 0 + if str(subscription.layer_2_circuit.layer_2_circuit_sides[0].sbp.edge_port.owner_subscription_id) + == old_edge_port + else 1 + ) + subscription.layer_2_circuit.layer_2_circuit_sides[replace_index].sbp.edge_port = EdgePort.from_subscription( + new_edge_port + ).edge_port + + vc_id = generate_unique_vc_id(l2c_type=subscription.layer_2_circuit.layer_2_circuit_type) + if not vc_id: + msg = "Failed to generate unique Virtual Circuit ID." + raise ProcessFailureError(msg) + + subscription.layer_2_circuit.virtual_circuit_id = vc_id + + return {"subscription": subscription} + + +@workflow( + "Migrate Layer 2 Circuit", + initial_input_form=wrap_modify_initial_input_form(input_form_generator), + target=Target.MODIFY, +) +def migrate_layer_2_circuit() -> StepList: + """Migrate a Layer 2 Circuit.""" + run_old_side_ansible = conditional(lambda state: state["run_old_side_ansible"]) + run_new_side_ansible = conditional(lambda state: state["run_new_side_ansible"]) + + return ( + begin + >> store_process_subscription(Target.MODIFY) + >> unsync + >> run_old_side_ansible(generate_fqdn_list) + >> run_old_side_ansible(extract_partner_name_from_edge_port) + >> run_old_side_ansible(lso_interaction(terminate_l2circuit_dry)) + >> run_old_side_ansible(lso_interaction(terminate_l2circuit_real)) + >> update_subscription_model + >> run_new_side_ansible(generate_fqdn_list) + >> run_new_side_ansible(extract_partner_name_from_edge_port) + >> run_new_side_ansible(lso_interaction(provision_l2circuit_dry)) + >> run_new_side_ansible(lso_interaction(provision_l2circuit_real)) + >> resync + >> done + ) diff --git a/gso/workflows/l2_circuit/modify_layer_2_circuit.py b/gso/workflows/l2_circuit/modify_layer_2_circuit.py index 91c791359b99194e9e089e7a0823b3b857aa37af..ae879037c0700a515ca7d2507fbad6eba7d83715 100644 --- a/gso/workflows/l2_circuit/modify_layer_2_circuit.py +++ b/gso/workflows/l2_circuit/modify_layer_2_circuit.py @@ -3,7 +3,7 @@ from orchestrator import begin, done, workflow from orchestrator.forms import FormPage, SubmitFormPage from orchestrator.targets import Target -from orchestrator.workflow import StepList, step +from orchestrator.workflow import StepList, conditional, step from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form from pydantic import BaseModel, ConfigDict, Field @@ -13,10 +13,17 @@ from pydantic_forms.validators import Divider, Label, ReadOnlyField from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_types.edge_port import EdgePort from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import lso_interaction from gso.services.partners import get_partner_by_id from gso.utils.types.interfaces import BandwidthString from gso.utils.types.tt_number import TTNumber from gso.utils.types.virtual_identifiers import VLAN_ID +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + generate_fqdn_list, + provision_l2circuit_dry, + provision_l2circuit_real, +) def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -36,6 +43,9 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: policer_enabled: bool = subscription.layer_2_circuit.policer_enabled custom_service_name: str | None = subscription.layer_2_circuit.custom_service_name + label: Label = Field("Should this workflow execute Ansible playbooks on routers?", exclude=True) + run_ansible_steps: bool = True + layer_2_circuit_input = yield ModifyL2CircuitForm class ModifyLayer2CircuitServiceSidesPage(SubmitFormPage): @@ -127,11 +137,17 @@ def modify_layer_2_circuit_subscription( ) def modify_layer_2_circuit() -> StepList: """Modify an existing Layer 2 Circuit service subscription.""" + run_ansible_steps = conditional(lambda state: state["run_ansible_steps"]) + return ( begin >> store_process_subscription(Target.MODIFY) >> unsync + >> run_ansible_steps(generate_fqdn_list) + >> run_ansible_steps(extract_partner_name_from_edge_port) >> modify_layer_2_circuit_subscription + >> run_ansible_steps(lso_interaction(provision_l2circuit_dry)) + >> run_ansible_steps(lso_interaction(provision_l2circuit_real)) >> resync >> done ) diff --git a/gso/workflows/l2_circuit/shared_steps.py b/gso/workflows/l2_circuit/shared_steps.py new file mode 100644 index 0000000000000000000000000000000000000000..48b6429b6405afdfc1f46fcbed48f4dfe4a99c3c --- /dev/null +++ b/gso/workflows/l2_circuit/shared_steps.py @@ -0,0 +1,121 @@ +"""Workflow steps that are used in multiple Layer 2 Circuit workflows.""" + +from copy import deepcopy +from typing import Any + +from orchestrator import step +from pydantic_forms.types import State, UUIDstr + +from gso.products.product_types.edge_port import EdgePort +from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import LSOState +from gso.services.partners import get_partner_by_id +from gso.utils.types.tt_number import TTNumber + + +@step("Generate FQDN list") +def generate_fqdn_list(subscription: Layer2Circuit) -> State: + """Generate the list of FQDNs that this workflow should target. + + This list will consist of two elements, one for each far end of the circuit. + """ + return { + "fqdn_list": [ + side.sbp.edge_port.node.router_fqdn for side in subscription.layer_2_circuit.layer_2_circuit_sides + ] + } + + +@step("Expand subscription dictionary") +def extract_partner_name_from_edge_port(subscription: dict[str, Any]) -> State: + """Expand a subscription model of a Layer 2 Circuit. + + This method will include the name of each Edge Port's partner to be used in Ansible playbooks. + """ + modified_subscription = deepcopy(subscription) + for side in modified_subscription["layer_2_circuit"]["layer_2_circuit_sides"]: + side["sbp"]["edge_port"]["partner_name"] = get_partner_by_id( + EdgePort.from_subscription(side["sbp"]["edge_port"]["owner_subscription_id"]).customer_id + ).name + + return {"modified_subscription": modified_subscription} + + +@step("[DRY RUN] Deploy L2circuit") +def provision_l2circuit_dry( + modified_subscription: dict[str, Any], process_id: UUIDstr, tt_number: str, fqdn_list: list[str] +) -> LSOState: + """Perform a dry run of deploying a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": True, + "verb": "deploy", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Deploy config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } + + +@step("[REAL RUN] Deploy L2circuit") +def provision_l2circuit_real( + modified_subscription: dict[str, Any], process_id: UUIDstr, tt_number: str, fqdn_list: list[str] +) -> LSOState: + """Perform a dry run of deploying a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": False, + "verb": "deploy", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Deploy config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } + + +@step("[DRY RUN] Remove old config") +def terminate_l2circuit_dry( + process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str] +) -> LSOState: + """Perform a dry run of removing old configuration of a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": True, + "verb": "terminate", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Remove config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } + + +@step("[FOR REAL] Remove old config") +def terminate_l2circuit_real( + process_id: UUIDstr, tt_number: TTNumber, modified_subscription: dict[str, Any], fqdn_list: list[str] +) -> LSOState: + """Remove old configuration of a Layer 2 Circuit.""" + extra_vars = { + "subscription": modified_subscription, + "dry_run": False, + "verb": "terminate", + "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - " + f"Remove config for {modified_subscription["description"]}", + } + + return { + "playbook_name": "gap_ansible/playbooks/l2circuit.yaml", + "inventory": {"all": {"hosts": dict.fromkeys(fqdn_list)}}, + "extra_vars": extra_vars, + } diff --git a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py index e8773a678ea5ce19c0dddef11e19d036f622460e..bab755eb824c8d92f0ed1e91d815eaf6a9df1fb4 100644 --- a/gso/workflows/l2_circuit/terminate_layer_2_circuit.py +++ b/gso/workflows/l2_circuit/terminate_layer_2_circuit.py @@ -4,13 +4,22 @@ from orchestrator import begin, workflow from orchestrator.forms import SubmitFormPage from orchestrator.targets import Target from orchestrator.types import SubscriptionLifecycle -from orchestrator.workflow import StepList, done +from orchestrator.workflow import StepList, conditional, done from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form +from pydantic import Field from pydantic_forms.types import FormGenerator, UUIDstr +from pydantic_forms.validators import Label from gso.products.product_types.layer_2_circuit import Layer2Circuit +from gso.services.lso_client import lso_interaction from gso.utils.types.tt_number import TTNumber +from gso.workflows.l2_circuit.shared_steps import ( + extract_partner_name_from_edge_port, + generate_fqdn_list, + terminate_l2circuit_dry, + terminate_l2circuit_real, +) def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -19,8 +28,11 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator: class TerminateForm(SubmitFormPage): tt_number: TTNumber - yield TerminateForm - return {"subscription": layer_2_circuit} + label: Label = Field("Should this workflow run Ansible playbooks to remove configuration from routers?") + run_ansible_steps: bool = True + + user_input = yield TerminateForm + return {"subscription": layer_2_circuit} | user_input.model_dump() @workflow( @@ -30,10 +42,16 @@ def _input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) def terminate_layer_2_circuit() -> StepList: """Terminate a Layer 2 Circuit subscription.""" + run_ansible_steps = conditional(lambda state: state["run_ansible_steps"]) + return ( begin >> store_process_subscription(Target.TERMINATE) >> unsync + >> run_ansible_steps(generate_fqdn_list) + >> run_ansible_steps(extract_partner_name_from_edge_port) + >> run_ansible_steps(lso_interaction(terminate_l2circuit_dry)) + >> run_ansible_steps(lso_interaction(terminate_l2circuit_real)) >> set_status(SubscriptionLifecycle.TERMINATED) >> resync >> done diff --git a/gso/workflows/l3_core_service/validate_prefix_list.py b/gso/workflows/l3_core_service/validate_prefix_list.py index 9104f04a35500d98ab72bd4d856407a6e9cd6481..9c9b6cd4321e07d78ce98524487d880f8b1cfb54 100644 --- a/gso/workflows/l3_core_service/validate_prefix_list.py +++ b/gso/workflows/l3_core_service/validate_prefix_list.py @@ -114,6 +114,7 @@ def validate_prefix_list() -> StepList: prefix_list_should_be_validated = conditional( lambda state: state["subscription"]["l3_core_service_type"] == L3CoreServiceType.GEANT_IP ) + fqdn_list_is_empty = conditional(lambda state: state["ap_fqdn_list"] == []) prefix_list_has_drifted = conditional(lambda state: bool(state["prefix_list_drift"])) redeploy_prefix_list_steps = ( @@ -134,6 +135,7 @@ def validate_prefix_list() -> StepList: begin >> store_process_subscription(Target.SYSTEM) >> build_fqdn_list + >> fqdn_list_is_empty(done) >> prefix_list_should_be_validated(prefix_list_validation_steps) >> done ) diff --git a/gso/workflows/router/promote_p_to_pe.py b/gso/workflows/router/promote_p_to_pe.py index ce961ed10581017bc7a592031fd36c1197c8cad7..25d4363360ace0cee93d5895089992021a6e90ec 100644 --- a/gso/workflows/router/promote_p_to_pe.py +++ b/gso/workflows/router/promote_p_to_pe.py @@ -30,11 +30,11 @@ from gso.utils.workflow_steps import ( add_pe_to_all_p_real, add_pe_to_pe_mesh_dry, add_pe_to_pe_mesh_real, + add_pe_to_sdp_mesh_dry, + add_pe_to_sdp_mesh_real, check_l3_services, check_pe_ibgp, create_kentik_device, - update_sdp_mesh_dry, - update_sdp_mesh_real, ) @@ -308,8 +308,8 @@ def promote_p_to_pe() -> StepList: >> lso_interaction(deploy_routing_instances_dry) >> lso_interaction(deploy_routing_instances_real) >> lso_interaction(check_l3_services) - >> lso_interaction(update_sdp_mesh_dry) - >> lso_interaction(update_sdp_mesh_real) + >> lso_interaction(add_pe_to_sdp_mesh_dry) + >> lso_interaction(add_pe_to_sdp_mesh_real) >> lso_interaction(add_all_p_to_pe_dry) >> lso_interaction(add_all_p_to_pe_real) >> lso_interaction(add_pe_to_all_p_dry) diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py index b5464513ef0d5cb0574d826ca65883ea68e8e03f..4599c6f099555b79a9a5a6a5f1229f4878dd8013 100644 --- a/gso/workflows/router/terminate_router.py +++ b/gso/workflows/router/terminate_router.py @@ -49,6 +49,10 @@ from gso.settings import load_oss_params from gso.utils.helpers import generate_inventory_for_routers from gso.utils.shared_enums import Vendor from gso.utils.types.tt_number import TTNumber +from gso.utils.workflow_steps import ( + remove_pe_from_sdp_mesh_dry, + remove_pe_from_sdp_mesh_real, +) logger = logging.getLogger(__name__) @@ -67,9 +71,11 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: tt_number: TTNumber termination_label: Label = "Please confirm whether configuration should get removed from the router." - remove_configuration: bool = True + remove_configuration: bool = False update_ibgp_mesh_label: Label = "Please confirm whether the iBGP mesh should get updated." update_ibgp_mesh: bool = True + update_sdp_mesh_label: Label = "Please confirm whether the SDP mesh should get updated." + update_sdp_mesh: bool = True user_input = yield TerminateForm return user_input.model_dump() | { @@ -332,6 +338,7 @@ def terminate_router() -> StepList: """ run_config_steps = conditional(lambda state: state["remove_configuration"]) update_ibgp_mesh = conditional(lambda state: state["update_ibgp_mesh"]) + update_sdp_mesh = conditional(lambda state: state["update_sdp_mesh"]) router_is_nokia = conditional(lambda state: state["router_is_nokia"]) router_is_pe = conditional(lambda state: state["router_role"] == RouterRole.PE) router_is_p = conditional(lambda state: state["router_role"] == RouterRole.P) @@ -346,6 +353,8 @@ def terminate_router() -> StepList: >> update_ibgp_mesh(router_is_pe(lso_interaction(remove_pe_from_all_pe_real))) >> update_ibgp_mesh(router_is_pe(lso_interaction(remove_pe_from_all_p_dry))) >> update_ibgp_mesh(router_is_pe(lso_interaction(remove_pe_from_all_p_real))) + >> update_sdp_mesh(router_is_pe(lso_interaction(remove_pe_from_sdp_mesh_dry))) + >> update_sdp_mesh(router_is_pe(lso_interaction(remove_pe_from_sdp_mesh_real))) >> deprovision_loopback_ips >> run_config_steps(lso_interaction(remove_config_from_router_dry)) >> run_config_steps(lso_interaction(remove_config_from_router_real)) diff --git a/gso/workflows/router/update_ibgp_mesh.py b/gso/workflows/router/update_ibgp_mesh.py index 2c8c9db00fbc54649a8182f236e48648a899e359..d04a50946f6a7567335e800adb9698e1683bc926 100644 --- a/gso/workflows/router/update_ibgp_mesh.py +++ b/gso/workflows/router/update_ibgp_mesh.py @@ -48,10 +48,10 @@ from gso.utils.workflow_steps import ( add_pe_to_all_p_real, add_pe_to_pe_mesh_dry, add_pe_to_pe_mesh_real, + add_pe_to_sdp_mesh_dry, + add_pe_to_sdp_mesh_real, check_l3_services, check_pe_ibgp, - update_sdp_mesh_dry, - update_sdp_mesh_real, update_sdp_single_pe_dry, update_sdp_single_pe_real, ) @@ -251,8 +251,8 @@ def update_ibgp_mesh() -> StepList: >> router_is_pe(lso_interaction(add_pe_to_all_p_real)) >> router_is_pe(lso_interaction(update_sdp_single_pe_dry)) >> router_is_pe(lso_interaction(update_sdp_single_pe_real)) - >> router_is_pe(lso_interaction(update_sdp_mesh_dry)) - >> router_is_pe(lso_interaction(update_sdp_mesh_real)) + >> router_is_pe(lso_interaction(add_pe_to_sdp_mesh_dry)) + >> router_is_pe(lso_interaction(add_pe_to_sdp_mesh_real)) >> router_is_pe(lso_interaction(check_pe_ibgp)) >> router_is_pe(lso_interaction(check_l3_services)) >> add_device_to_librenms diff --git a/pyproject.toml b/pyproject.toml index 6fb91172edfe7f78200037184ba51e1f77cad78f..34e477ffd71d29f1bd31b5d2ea0282e99d0ed05a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ ignore = [ "PLR0913", "PLR0904", "PLW1514", + "S311", ] select = [ "A", diff --git a/setup.py b/setup.py index f0cf0320874b1d1fb4e6fbedea918c2dad9f9e16..fb8eab0f1e24782746e85d98d123c871fb79fc2f 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="2.45", + version="2.46", author="GÉANT Orchestration and Automation Team", author_email="goat@geant.org", description="GÉANT Service Orchestrator", diff --git a/test/cli/test_imports.py b/test/cli/test_imports.py index bc882410526e73dde39025e5da0adb91bd8881a5..26ab3bdd918da528e4c2f1564a8ced089caf803c 100644 --- a/test/cli/test_imports.py +++ b/test/cli/test_imports.py @@ -405,11 +405,12 @@ def l3_core_service_data(temp_file, faker, partner_factory, edge_port_subscripti @pytest.fixture() def layer_2_circuit_data(temp_file, faker, partner_factory, edge_port_subscription_factory): def _layer_2_circuit_data(**kwargs): + circuit_type = Layer2CircuitType.VLAN layer_2_circuit_input_data = { "partner": partner_factory()["name"], "service_type": Layer2CircuitServiceType.GEANT_PLUS, "gs_id": faker.imported_gs_id(), - "vc_id": generate_unique_vc_id(), + "vc_id": generate_unique_vc_id(circuit_type), "layer_2_circuit_side_a": { "edge_port": str(edge_port_subscription_factory().subscription_id), "vlan_id": faker.vlan_id(), @@ -418,7 +419,7 @@ def layer_2_circuit_data(temp_file, faker, partner_factory, edge_port_subscripti "edge_port": str(edge_port_subscription_factory().subscription_id), "vlan_id": faker.vlan_id(), }, - "layer_2_circuit_type": Layer2CircuitType.VLAN, + "layer_2_circuit_type": circuit_type, "vlan_range_lower_bound": faker.vlan_id(), "vlan_range_upper_bound": faker.vlan_id(), "policer_enabled": False, diff --git a/test/fixtures/edge_port_fixtures.py b/test/fixtures/edge_port_fixtures.py index ad6da994a083b5746107d381d831de6dc1a291ff..7ec1e0d9357e1e6174116c409f4e400a4ad78b95 100644 --- a/test/fixtures/edge_port_fixtures.py +++ b/test/fixtures/edge_port_fixtures.py @@ -16,7 +16,7 @@ from gso.utils.types.interfaces import PhysicalPortCapacity @pytest.fixture() -def edge_port_subscription_factory(faker, partner_factory, router_subscription_factory): +def edge_port_subscription_factory(faker, geant_partner, router_subscription_factory): def subscription_create( description=None, partner: dict | None = None, @@ -38,7 +38,8 @@ def edge_port_subscription_factory(faker, partner_factory, router_subscription_f ignore_if_down=False, is_imported=False, ) -> SubscriptionModel: - partner = partner or partner_factory() + # Use default partner if none defined + partner = partner or geant_partner node = node or router_subscription_factory(vendor=Vendor.NOKIA).router if is_imported: diff --git a/test/fixtures/l3_core_service_fixtures.py b/test/fixtures/l3_core_service_fixtures.py index bffe03dae1e11dfe8e06e5b2cd4bd02543cc4627..fc72ca3998ccec8c03e893f4178c452fbabf6e6f 100644 --- a/test/fixtures/l3_core_service_fixtures.py +++ b/test/fixtures/l3_core_service_fixtures.py @@ -133,7 +133,7 @@ def access_port_factory(faker, service_binding_port_factory): ): return AccessPort.new( subscription_id=uuid4(), - ap_type=ap_type or random.choice(list(APType)), # noqa: S311 + ap_type=ap_type or random.choice(list(APType)), sbp=service_binding_port or service_binding_port_factory(edge_port=edge_port, partner=partner), ) diff --git a/test/fixtures/layer_2_circuit_fixtures.py b/test/fixtures/layer_2_circuit_fixtures.py index f00b3375ce9da225e07e0259991019aa137066fc..584df642c94b7f010f0d219fa95498a5aafc58e1 100644 --- a/test/fixtures/layer_2_circuit_fixtures.py +++ b/test/fixtures/layer_2_circuit_fixtures.py @@ -96,22 +96,16 @@ def layer_2_circuit_subscription_factory(faker, geant_partner, edge_port_subscri layer_2_circuit_sides.append(layer_2_circuit_side) subscription.layer_2_circuit.layer_2_circuit_sides = layer_2_circuit_sides - subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id() + subscription.layer_2_circuit.virtual_circuit_id = generate_unique_vc_id(layer_2_circuit_type) subscription.layer_2_circuit.layer_2_circuit_type = layer_2_circuit_type if layer_2_circuit_type == Layer2CircuitType.VLAN: subscription.layer_2_circuit.vlan_range_lower_bound = vlan_range_lower_bound or faker.vlan_id() subscription.layer_2_circuit.vlan_range_upper_bound = vlan_range_upper_bound or faker.vlan_id() - else: - subscription.layer_2_circuit.vlan_range_lower_bound = None - subscription.layer_2_circuit.vlan_range_upper_bound = None subscription.layer_2_circuit.policer_enabled = policer_enabled if policer_enabled: subscription.layer_2_circuit.bandwidth = policer_bandwidth or faker.bandwidth() subscription.layer_2_circuit.policer_burst_rate = policer_burst_rate or faker.bandwidth() - else: - subscription.layer_2_circuit.bandwidth = None - subscription.layer_2_circuit.policer_burst_rate = None subscription.description = description or ( f"{subscription.product.name} - {subscription.layer_2_circuit.virtual_circuit_id}" ) diff --git a/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py b/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py index 49e19bc7c693e3a1f9d5be14cbab6898a439f18b..84d894d98c25784406b86d032575991676acda74 100644 --- a/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_create_imported_layer_2_circuit.py @@ -17,15 +17,16 @@ def test_create_imported_layer_2_circuit_success( edge_port_a = str(edge_port_subscription_factory(partner=partner).subscription_id) edge_port_b = str(edge_port_subscription_factory(partner=partner).subscription_id) policer_enabled = faker.boolean() + circuit_type = Layer2CircuitType.VLAN creation_form_input_data = [ { "service_type": layer_2_service_type, "partner": partner["name"], - "layer_2_circuit_type": Layer2CircuitType.VLAN, + "layer_2_circuit_type": circuit_type, "policer_enabled": policer_enabled, "vlan_range_lower_bound": faker.vlan_id(), "vlan_range_upper_bound": faker.vlan_id(), - "vc_id": generate_unique_vc_id(), + "vc_id": generate_unique_vc_id(circuit_type), "policer_bandwidth": faker.bandwidth() if policer_enabled else None, "policer_burst_rate": faker.bandwidth() if policer_enabled else None, "gs_id": faker.imported_gs_id(), diff --git a/test/workflows/l2_circuit/test_create_layer_2_circuit.py b/test/workflows/l2_circuit/test_create_layer_2_circuit.py index ecec066a438b591d14b138bf16a3be93a4b1735d..4d282f6f73f5f2df113a069c75a2f3126773c2d6 100644 --- a/test/workflows/l2_circuit/test_create_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_create_layer_2_circuit.py @@ -1,10 +1,12 @@ +from unittest.mock import patch + import pytest from orchestrator.types import SubscriptionLifecycle from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit from gso.services.subscriptions import get_product_id_by_name -from test.workflows import assert_complete, extract_state, run_workflow +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow def generate_layer_2_circuit_input( @@ -58,16 +60,23 @@ def layer_2_circuit_ethernet_input( @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_create_layer_2_circuit_success( + mock_lso_interaction, layer_2_circuit_service_type, layer_2_circuit_input, faker, partner_factory, ): - result, _, _ = run_workflow("create_layer_2_circuit", layer_2_circuit_input) + result, process_stat, step_log = run_workflow("create_layer_2_circuit", layer_2_circuit_input) + + for _ in range(2): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Layer2Circuit.from_subscription(state["subscription_id"]) + assert mock_lso_interaction.call_count == 2 assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.virtual_circuit_id is not None assert len(subscription.layer_2_circuit.layer_2_circuit_sides) == 2 @@ -104,16 +113,23 @@ def test_create_layer_2_circuit_success( @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_create_layer_2_circuit_with_ethernet_type( + mock_lso_interaction, layer_2_circuit_service_type, layer_2_circuit_ethernet_input, faker, partner_factory, ): - result, _, _ = run_workflow("create_layer_2_circuit", layer_2_circuit_ethernet_input) + result, process_stat, step_log = run_workflow("create_layer_2_circuit", layer_2_circuit_ethernet_input) + + for _ in range(2): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Layer2Circuit.from_subscription(state["subscription_id"]) + assert mock_lso_interaction.call_count == 2 assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.virtual_circuit_id is not None assert len(subscription.layer_2_circuit.layer_2_circuit_sides) == 2 diff --git a/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py new file mode 100644 index 0000000000000000000000000000000000000000..9c016cd28a5abbbd1a3d9cfd8572eeb78c5fedff --- /dev/null +++ b/test/workflows/l2_circuit/test_migrate_layer_2_circuit.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +import pytest + +from gso.products.product_types.edge_port import EdgePort +from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit +from gso.products.product_types.router import Router +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow + + +@pytest.mark.workflow() +@pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) +@pytest.mark.parametrize("run_old_side_ansible", [False, True]) +@pytest.mark.parametrize("run_new_side_ansible", [False, True]) +@patch("gso.services.lso_client._send_request") +def test_migrate_layer_2_circuit( + mock_lso_interaction, + run_new_side_ansible, + run_old_side_ansible, + layer_2_circuit_service_type, + layer_2_circuit_subscription_factory, + edge_port_subscription_factory, + faker, +): + subscription = layer_2_circuit_subscription_factory(layer_2_circuit_service_type=layer_2_circuit_service_type) + side_b_router = Router.from_subscription( + subscription.layer_2_circuit.layer_2_circuit_sides[1].sbp.edge_port.node.owner_subscription_id + ) + old_edge_port = EdgePort.from_subscription( + subscription.layer_2_circuit.layer_2_circuit_sides[1].sbp.edge_port.owner_subscription_id + ) + new_edge_port = edge_port_subscription_factory(node=side_b_router.router) + + initial_layer_2_circuit_data = [ + {"subscription_id": subscription.subscription_id}, + { + "tt_number": faker.tt_number(), + "replace_side": old_edge_port.subscription_id, + "migrate_to_different_site": False, + "run_old_side_ansible": run_old_side_ansible, + "run_new_side_ansible": run_new_side_ansible, + }, + {"new_edge_port": new_edge_port.subscription_id}, + ] + + result, process_stat, step_log = run_workflow("migrate_layer_2_circuit", initial_layer_2_circuit_data) + + lso_step_count = 0 + lso_step_count += 2 if run_old_side_ansible else 0 + lso_step_count += 2 if run_new_side_ansible else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Layer2Circuit.from_subscription(subscription_id) + assert subscription.status == "active" + assert mock_lso_interaction.call_count == lso_step_count + + replaced_edge_port = subscription.layer_2_circuit.layer_2_circuit_sides[1].sbp.edge_port + assert replaced_edge_port.model_dump(exclude="edge_port_ae_members") == new_edge_port.edge_port.model_dump( + exclude="edge_port_ae_members" + ) + assert replaced_edge_port.edge_port_ae_members[0].model_dump( + exclude="owner_subscription_id" + ) == new_edge_port.edge_port.edge_port_ae_members[0].model_dump(exclude="owner_subscription_id") + assert replaced_edge_port.edge_port_ae_members[1].model_dump( + exclude="owner_subscription_id" + ) == new_edge_port.edge_port.edge_port_ae_members[1].model_dump(exclude="owner_subscription_id") diff --git a/test/workflows/l2_circuit/test_modify_layer_2_circuit.py b/test/workflows/l2_circuit/test_modify_layer_2_circuit.py index cc6f773c85c8cb3ececb9e86d240fa5fd5924c44..e4f4dcce33efeabe2dac11c54a41b014577ab0de 100644 --- a/test/workflows/l2_circuit/test_modify_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_modify_layer_2_circuit.py @@ -1,15 +1,21 @@ +from unittest.mock import patch + import pytest from orchestrator.types import SubscriptionLifecycle from gso.products.product_blocks.layer_2_circuit import Layer2CircuitType from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit -from test.workflows import assert_complete, extract_state, run_workflow +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) +@pytest.mark.parametrize("run_ansible_steps", [False, True]) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_modify_layer_2_circuit_change_policer_bandwidth( + mock_lso_interaction, layer_2_circuit_service_type, + run_ansible_steps, layer_2_circuit_subscription_factory, faker, partner_factory, @@ -22,6 +28,7 @@ def test_modify_layer_2_circuit_change_policer_bandwidth( "layer_2_circuit_type": Layer2CircuitType.VLAN, "policer_enabled": False, "custom_service_name": faker.sentence(), + "run_ansible_steps": run_ansible_steps, }, { "vlan_range_lower_bound": subscription.layer_2_circuit.vlan_range_lower_bound, @@ -32,9 +39,16 @@ def test_modify_layer_2_circuit_change_policer_bandwidth( "layer_2_circuit_side_b": {}, }, ] - result, _, _ = run_workflow("modify_layer_2_circuit", input_form_data) + result, process_stat, step_log = run_workflow("modify_layer_2_circuit", input_form_data) + + lso_step_count = 2 if run_ansible_steps else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + subscription = Layer2Circuit.from_subscription(str(subscription.subscription_id)) assert_complete(result) + assert mock_lso_interaction.call_count == lso_step_count assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.policer_enabled is False assert subscription.layer_2_circuit.bandwidth is None @@ -54,9 +68,13 @@ def test_modify_layer_2_circuit_change_policer_bandwidth( @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) +@pytest.mark.parametrize("run_ansible_steps", [False, True]) @pytest.mark.workflow() +@patch("gso.services.lso_client._send_request") def test_modify_layer_2_circuit_change_circuit_type( + mock_lso_interaction, layer_2_circuit_service_type, + run_ansible_steps, layer_2_circuit_subscription_factory, faker, partner_factory, @@ -67,6 +85,7 @@ def test_modify_layer_2_circuit_change_circuit_type( { "tt_number": faker.tt_number(), "layer_2_circuit_type": Layer2CircuitType.ETHERNET, + "run_ansible_steps": run_ansible_steps, }, { "vlan_range_lower_bound": None, @@ -76,10 +95,17 @@ def test_modify_layer_2_circuit_change_circuit_type( "layer_2_circuit_side_b": {"vlan_id": faker.vlan_id()}, }, ] - result, _, _ = run_workflow("modify_layer_2_circuit", input_form_data) + result, process_stat, step_log = run_workflow("modify_layer_2_circuit", input_form_data) + + lso_step_count = 2 if run_ansible_steps else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Layer2Circuit.from_subscription(state["subscription_id"]) + assert mock_lso_interaction.call_count == lso_step_count assert subscription.status == SubscriptionLifecycle.ACTIVE assert subscription.layer_2_circuit.vlan_range_lower_bound is None assert subscription.layer_2_circuit.vlan_range_upper_bound is None diff --git a/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py b/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py index 750c7b06c06984229940dca59bfaa0751a085d17..55deedededc9c3c43ef3ca56d6145508cbf7f5d4 100644 --- a/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py +++ b/test/workflows/l2_circuit/test_terminate_layer_2_circuit.py @@ -1,20 +1,36 @@ +from unittest.mock import patch + import pytest from gso.products.product_types.layer_2_circuit import LAYER_2_CIRCUIT_SERVICE_TYPES, Layer2Circuit -from test.workflows import assert_complete, extract_state, run_workflow +from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow @pytest.mark.workflow() @pytest.mark.parametrize("layer_2_circuit_service_type", LAYER_2_CIRCUIT_SERVICE_TYPES) -def test_terminate_layer_2_circuit(layer_2_circuit_service_type, layer_2_circuit_subscription_factory, faker): +@pytest.mark.parametrize("run_ansible_steps", [True, False]) +@patch("gso.services.lso_client._send_request") +def test_terminate_layer_2_circuit( + mock_lso_interaction, layer_2_circuit_service_type, run_ansible_steps, layer_2_circuit_subscription_factory, faker +): subscription_id = str( layer_2_circuit_subscription_factory(layer_2_circuit_service_type=layer_2_circuit_service_type).subscription_id ) - initialt_layer_2_circuit_data = [{"subscription_id": subscription_id}, {"tt_number": faker.tt_number()}] - result, _, _ = run_workflow("terminate_layer_2_circuit", initialt_layer_2_circuit_data) + initial_layer_2_circuit_data = [ + {"subscription_id": subscription_id}, + {"tt_number": faker.tt_number(), "run_ansible_steps": run_ansible_steps}, + ] + result, process_stat, step_log = run_workflow("terminate_layer_2_circuit", initial_layer_2_circuit_data) + + lso_step_count = 2 if run_ansible_steps else 0 + + for _ in range(lso_step_count): + result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription_id = state["subscription_id"] subscription = Layer2Circuit.from_subscription(subscription_id) assert subscription.status == "terminated" + assert mock_lso_interaction.call_count == lso_step_count diff --git a/test/workflows/l3_core_service/test_validate_prefix_list.py b/test/workflows/l3_core_service/test_validate_prefix_list.py index c4fe2ed976d1e89d555838a2fb54e8e90b0c586a..5ec52d8e58a29f9dd6f7c05a587a5ae83cf63a01 100644 --- a/test/workflows/l3_core_service/test_validate_prefix_list.py +++ b/test/workflows/l3_core_service/test_validate_prefix_list.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from gso.products.product_types.l3_core_service import L3_CORE_SERVICE_TYPES, L3CoreService, L3CoreServiceType -from gso.utils.shared_enums import Vendor +from gso.utils.shared_enums import APType, Vendor from test import USER_CONFIRM_EMPTY_FORM from test.workflows import ( assert_complete, @@ -41,9 +41,6 @@ def test_validate_prefix_list_success( subscription = L3CoreService.from_subscription(subscription_id) assert subscription.status == "active" assert subscription.insync is True - # Verify the subscription has no Juniper devices - for ap in subscription.l3_core_service.ap_list: - assert ap.sbp.edge_port.node.vendor != Vendor.JUNIPER # Verify the number of LSO interactions assert mock_lso_interaction.call_count == (1 if should_run_validation else 0) @@ -103,3 +100,57 @@ def test_validate_prefix_list_without_diff(mock_lso_interaction, l3_core_service assert subscription.insync is True # Verify the number of LSO interactions assert mock_lso_interaction.call_count == 1 # Only validation is performed + + +@pytest.mark.workflow() +def test_validate_prefix_skip_on_juniper( + l3_core_service_subscription_factory, + access_port_factory, + service_binding_port_factory, + edge_port_subscription_factory, + router_subscription_factory, + faker, +): + """Test case where all APs are Juniper, and the workflow is effectively skipped.""" + ap_list = [ + access_port_factory( + ap_type=APType.PRIMARY, + service_binding_port=service_binding_port_factory( + edge_port=edge_port_subscription_factory(node=router_subscription_factory(vendor=Vendor.JUNIPER).router) + ), + ), + access_port_factory( + ap_type=APType.BACKUP, + service_binding_port=service_binding_port_factory( + edge_port=edge_port_subscription_factory(node=router_subscription_factory(vendor=Vendor.JUNIPER).router) + ), + ), + access_port_factory( + ap_type=APType.BACKUP, + service_binding_port=service_binding_port_factory( + edge_port=edge_port_subscription_factory(node=router_subscription_factory(vendor=Vendor.JUNIPER).router) + ), + ), + access_port_factory( + ap_type=APType.BACKUP, + service_binding_port=service_binding_port_factory( + edge_port=edge_port_subscription_factory(node=router_subscription_factory(vendor=Vendor.JUNIPER).router) + ), + ), + ] + subscription_id = str( + l3_core_service_subscription_factory( + l3_core_service_type=L3CoreServiceType.GEANT_IP, ap_list=ap_list + ).subscription_id + ) + initial_l3_core_service_data = [{"subscription_id": subscription_id}] + # Run the workflow and extract results + # Assert workflow completion since it is skipped if it is on a Juniper + result, _, _ = run_workflow("validate_prefix_list", initial_l3_core_service_data) + assert_complete(result) + # Extract the state and validate subscription attributes + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = L3CoreService.from_subscription(subscription_id) + assert subscription.status == "active" + assert subscription.insync is True diff --git a/test/workflows/router/test_terminate_router.py b/test/workflows/router/test_terminate_router.py index 6611bc0dc7574f729052f295194273afe3073b24..a760472efae75808a1a613d031eafb1494e1e18f 100644 --- a/test/workflows/router/test_terminate_router.py +++ b/test/workflows/router/test_terminate_router.py @@ -11,6 +11,7 @@ from test.workflows import assert_complete, assert_lso_interaction_success, extr @pytest.mark.workflow() @pytest.mark.parametrize("remove_configuration", [True, False]) @pytest.mark.parametrize("update_ibgp_mesh", [True, False]) +@pytest.mark.parametrize("update_sdp_mesh", [True, False]) @patch("gso.services.lso_client._send_request") @patch("gso.workflows.router.terminate_router.NetboxClient.delete_device") @patch("gso.workflows.router.terminate_router.infoblox.delete_host_by_ip") @@ -24,6 +25,7 @@ def test_terminate_pe_router_full_success( mock_execute_playbook, remove_configuration, update_ibgp_mesh, + update_sdp_mesh, router_subscription_factory, faker, ): @@ -36,12 +38,15 @@ def test_terminate_pe_router_full_success( "tt_number": faker.tt_number(), "remove_configuration": remove_configuration, "update_ibgp_mesh": update_ibgp_mesh, + "update_sdp_mesh": update_sdp_mesh, } lso_interaction_count = 0 if remove_configuration: lso_interaction_count += 2 if update_ibgp_mesh: lso_interaction_count += 4 + if update_sdp_mesh: + lso_interaction_count += 2 mock_kentik_client.return_value = MockedKentikClient # Run workflow initial_router_data = [{"subscription_id": product_id}, router_termination_input_form_data]