From ea06b9e1789f438335dccca363580f8f464862ff Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Wed, 4 Sep 2024 09:41:36 +0100
Subject: [PATCH] Implement Edge port workflows

---
 ...-27_75b5c3597bf4_add_edge_port_product.py} |  46 ++-
 ...27_c466b64eccfd_add_edge_port_workflows.py |  57 ++++
 gso/products/product_blocks/edge_port.py      |  18 +-
 gso/services/netbox_client.py                 |  18 +-
 gso/utils/device_info.py                      |   2 +-
 gso/utils/helpers.py                          |  34 +-
 gso/workflows/__init__.py                     |   7 +
 gso/workflows/edge_port/__init__.py           |   1 +
 gso/workflows/edge_port/create_edge_port.py   | 254 +++++++++++++++
 gso/workflows/edge_port/modify_edge_port.py   | 294 ++++++++++++++++++
 .../edge_port/terminate_edge_port.py          |  99 ++++++
 gso/workflows/edge_port/validate_edge_port.py |  91 ++++++
 12 files changed, 890 insertions(+), 31 deletions(-)
 rename gso/migrations/versions/{2024-08-23_6456d3a9d150_add_edge_port_product.py => 2024-08-27_75b5c3597bf4_add_edge_port_product.py} (89%)
 create mode 100644 gso/migrations/versions/2024-08-27_c466b64eccfd_add_edge_port_workflows.py
 create mode 100644 gso/workflows/edge_port/__init__.py
 create mode 100644 gso/workflows/edge_port/create_edge_port.py
 create mode 100644 gso/workflows/edge_port/modify_edge_port.py
 create mode 100644 gso/workflows/edge_port/terminate_edge_port.py
 create mode 100644 gso/workflows/edge_port/validate_edge_port.py

diff --git a/gso/migrations/versions/2024-08-23_6456d3a9d150_add_edge_port_product.py b/gso/migrations/versions/2024-08-27_75b5c3597bf4_add_edge_port_product.py
similarity index 89%
rename from gso/migrations/versions/2024-08-23_6456d3a9d150_add_edge_port_product.py
rename to gso/migrations/versions/2024-08-27_75b5c3597bf4_add_edge_port_product.py
index 17e2b524..b4253396 100644
--- a/gso/migrations/versions/2024-08-23_6456d3a9d150_add_edge_port_product.py
+++ b/gso/migrations/versions/2024-08-27_75b5c3597bf4_add_edge_port_product.py
@@ -1,15 +1,15 @@
 """Add Edge Port product..
 
-Revision ID: 6456d3a9d150
+Revision ID: 75b5c3597bf4
 Revises: 87a05eddee3e
-Create Date: 2024-08-23 09:51:21.029168
+Create Date: 2024-08-27 11:46:14.049679
 
 """
 import sqlalchemy as sa
 from alembic import op
 
 # revision identifiers, used by Alembic.
-revision = '6456d3a9d150'
+revision = '75b5c3597bf4'
 down_revision = '87a05eddee3e'
 branch_labels = None
 depends_on = None
@@ -18,40 +18,43 @@ depends_on = None
 def upgrade() -> None:
     conn = op.get_bind()
     conn.execute(sa.text("""
-INSERT INTO products (name, description, product_type, tag, status) VALUES ('Edge port', 'Edge Port product', 'EdgePort', 'EDGE_PORT', 'active') RETURNING products.product_id
+INSERT INTO products (name, description, product_type, tag, status) VALUES ('Edge port', 'Edge Port', 'EdgePort', 'EDGE_PORT', 'active') RETURNING products.product_id
     """))
     conn.execute(sa.text("""
-INSERT INTO product_blocks (name, description, tag, status) VALUES ('EdgePortBlock', 'Edge port product block.', 'EDGE_PORT_BLOCK', 'active') RETURNING product_blocks.product_block_id
+INSERT INTO product_blocks (name, description, tag, status) VALUES ('EdgePortBlock', 'Edge Port product block.', 'EDGE_PORT_BLOCK', 'active') RETURNING product_blocks.product_block_id
     """))
     conn.execute(sa.text("""
-INSERT INTO product_blocks (name, description, tag, status) VALUES ('EdgePortInterfaceBlock', 'Edge port interface block', 'EDGE_PORT_IFACE_BLK', 'active') RETURNING product_blocks.product_block_id
+INSERT INTO product_blocks (name, description, tag, status) VALUES ('EdgePortInterfaceBlock', 'Edge Port Iface product block.', 'EDGE_PORT_IFACE_BLCK', 'active') RETURNING product_blocks.product_block_id
     """))
     conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_name', 'The name of the edge port, In our case, this is the name of the LAG interface.') RETURNING resource_types.resource_type_id
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_name', 'The name of the edge port, in our case, corresponds to the name of the LAG interface.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_member_speed', 'The speed capacity of each member in the physical port.') RETURNING resource_types.resource_type_id
-    """))
-    conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_mac_address', 'The MAC address assigned to this edge port, if applicable.') RETURNING resource_types.resource_type_id
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_description', 'A description of the edge port.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
 INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_minimum_links', 'The minimum number of links required for this edge port.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_ignore_if_down', 'If set to True, the edge port will be ignored if it is down.') RETURNING resource_types.resource_type_id
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_enable_lacp', 'Indicates whether LACP (Link Aggregation Control Protocol) is enabled for this edge port.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
 INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_type', 'The type of edge port (e.g., customer, private, public).') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_encapsulation', 'The type of encapsulation used on this edge port, by default DOT1Q.') RETURNING resource_types.resource_type_id
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_geant_ga_id', 'The GEANT GA ID associated with this edge port, if any.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_enable_lacp', 'Indicates whether LACP (Link Aggregation Control Protocol) is enabled for this edge port.') RETURNING resource_types.resource_type_id
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_mac_address', 'The MAC address assigned to this edge port, if applicable.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
-INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_geant_ga_id', 'The GEANT GA ID associated with this edge port, if any.') RETURNING resource_types.resource_type_id
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_member_speed', 'The speed capacity of each member in the physical port.') RETURNING resource_types.resource_type_id
+    """))
+    conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_ignore_if_down', 'If set to True, the edge port will be ignored if it is down.') RETURNING resource_types.resource_type_id
+    """))
+    conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('edge_port_encapsulation', 'The type of encapsulation used on this edge port, by default DOT1Q.') RETURNING resource_types.resource_type_id
     """))
     conn.execute(sa.text("""
 INSERT INTO product_product_blocks (product_id, product_block_id) VALUES ((SELECT products.product_id FROM products WHERE products.name IN ('Edge port')), (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock')))
@@ -66,6 +69,9 @@ INSERT INTO product_block_relations (in_use_by_id, depends_on_id) VALUES ((SELEC
 INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_name')))
     """))
     conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_description')))
+    """))
+    conn.execute(sa.text("""
 INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_enable_lacp')))
     """))
     conn.execute(sa.text("""
@@ -106,6 +112,12 @@ DELETE FROM product_block_resource_types WHERE product_block_resource_types.prod
 DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_name'))
     """))
     conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_description'))
+    """))
+    conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_description'))
+    """))
+    conn.execute(sa.text("""
 DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_enable_lacp'))
     """))
     conn.execute(sa.text("""
@@ -166,10 +178,10 @@ DELETE FROM product_block_resource_types WHERE product_block_resource_types.prod
 DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortInterfaceBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_description'))
     """))
     conn.execute(sa.text("""
-DELETE FROM subscription_instance_values WHERE subscription_instance_values.resource_type_id IN (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_name', 'edge_port_member_speed', 'edge_port_mac_address', 'edge_port_minimum_links', 'edge_port_ignore_if_down', 'edge_port_type', 'edge_port_encapsulation', 'edge_port_enable_lacp', 'edge_port_geant_ga_id'))
+DELETE FROM subscription_instance_values WHERE subscription_instance_values.resource_type_id IN (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('edge_port_name', 'edge_port_description', 'edge_port_minimum_links', 'edge_port_enable_lacp', 'edge_port_type', 'edge_port_geant_ga_id', 'edge_port_mac_address', 'edge_port_member_speed', 'edge_port_ignore_if_down', 'edge_port_encapsulation'))
     """))
     conn.execute(sa.text("""
-DELETE FROM resource_types WHERE resource_types.resource_type IN ('edge_port_name', 'edge_port_member_speed', 'edge_port_mac_address', 'edge_port_minimum_links', 'edge_port_ignore_if_down', 'edge_port_type', 'edge_port_encapsulation', 'edge_port_enable_lacp', 'edge_port_geant_ga_id')
+DELETE FROM resource_types WHERE resource_types.resource_type IN ('edge_port_name', 'edge_port_description', 'edge_port_minimum_links', 'edge_port_enable_lacp', 'edge_port_type', 'edge_port_geant_ga_id', 'edge_port_mac_address', 'edge_port_member_speed', 'edge_port_ignore_if_down', 'edge_port_encapsulation')
     """))
     conn.execute(sa.text("""
 DELETE FROM product_product_blocks WHERE product_product_blocks.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Edge port')) AND product_product_blocks.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('EdgePortBlock'))
diff --git a/gso/migrations/versions/2024-08-27_c466b64eccfd_add_edge_port_workflows.py b/gso/migrations/versions/2024-08-27_c466b64eccfd_add_edge_port_workflows.py
new file mode 100644
index 00000000..f2bb88b8
--- /dev/null
+++ b/gso/migrations/versions/2024-08-27_c466b64eccfd_add_edge_port_workflows.py
@@ -0,0 +1,57 @@
+"""Add Edge Port workflows..
+
+Revision ID: c466b64eccfd
+Revises: 75b5c3597bf4
+Create Date: 2024-08-27 11:54:16.284844
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = 'c466b64eccfd'
+down_revision = '75b5c3597bf4'
+branch_labels = None
+depends_on = None
+
+
+from orchestrator.migrations.helpers import create_workflow, delete_workflow
+
+new_workflows = [
+    {
+        "name": "create_edge_port",
+        "target": "CREATE",
+        "description": "Create Edge Port",
+        "product_type": "EdgePort"
+    },
+    {
+        "name": "modify_edge_port",
+        "target": "MODIFY",
+        "description": "Modify Edge Port",
+        "product_type": "EdgePort"
+    },
+    {
+        "name": "terminate_edge_port",
+        "target": "TERMINATE",
+        "description": "Terminate Edge Port",
+        "product_type": "EdgePort"
+    },
+    {
+        "name": "validate_edge_port",
+        "target": "SYSTEM",
+        "description": "Validate Edge Port Configuration",
+        "product_type": "EdgePort"
+    }
+]
+
+
+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/products/product_blocks/edge_port.py b/gso/products/product_blocks/edge_port.py
index 2a59ef9a..bb8d7258 100644
--- a/gso/products/product_blocks/edge_port.py
+++ b/gso/products/product_blocks/edge_port.py
@@ -76,14 +76,15 @@ class EdgePortBlockInactive(
 ):
     """An edge port that's currently inactive. See :class:`EdgePortBlock`."""
 
-    node: RouterBlockInactive
-    edge_port_name: str
-    enable_lacp: bool
+    edge_port_node: RouterBlockInactive | None = None
+    edge_port_name: str | None = None
+    edge_port_description: str | None = None
+    edge_port_enable_lacp: bool | None = None
     edge_port_encapsulation: EncapsulationType = EncapsulationType.DOT1Q
     edge_port_mac_address: str | None = None
-    edge_port_member_speed: PhysicalPortCapacity
+    edge_port_member_speed: PhysicalPortCapacity | None = None
     edge_port_minimum_links: int | None = None
-    edge_port_type: EdgePortType
+    edge_port_type: EdgePortType | None = None
     edge_port_ignore_if_down: bool = False
     edge_port_geant_ga_id: str | None = None
     edge_port_ae_members: LAGMemberList[EdgePortInterfaceBlockInactive]
@@ -92,8 +93,9 @@ class EdgePortBlockInactive(
 class EdgePortBlockProvisioning(EdgePortBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
     """An edge port that's being provisioned. See :class:`EdgePortBlock`."""
 
-    node: RouterBlockProvisioning
+    edge_port_node: RouterBlockProvisioning
     edge_port_name: str
+    edge_port_description: str | None = None
     edge_port_enable_lacp: bool
     edge_port_encapsulation: EncapsulationType = EncapsulationType.DOT1Q
     edge_port_mac_address: str | None = None
@@ -109,9 +111,11 @@ class EdgePortBlock(EdgePortBlockProvisioning, lifecycle=[SubscriptionLifecycle.
     """An edge port that's currently deployed in the network."""
 
     #: The router that this edge port is connected to.
-    node: RouterBlock
+    edge_port_node: RouterBlock
     #: The name of the edge port, in our case, corresponds to the name of the LAG interface.
     edge_port_name: str
+    #: A description of the edge port.
+    edge_port_description: str | None = None
     #: Indicates whether LACP (Link Aggregation Control Protocol) is enabled for this edge port.
     edge_port_enable_lacp: bool
     #: The type of encapsulation used on this edge port, by default DOT1Q.
diff --git a/gso/services/netbox_client.py b/gso/services/netbox_client.py
index b79283cc..2566f603 100644
--- a/gso/services/netbox_client.py
+++ b/gso/services/netbox_client.py
@@ -289,22 +289,30 @@ class NetboxClient:
             interface.lag = None
             interface.save()
 
-    def get_available_lags(self, router_id: UUID) -> list[str]:
-        """Return all available :term:`LAG` not assigned to a device."""
+    def get_available_lags_in_range(self, router_id: UUID, lag_range: range) -> list[str]:
+        """Return all available LAGs within a given range not assigned to a device."""
         router_name = Router.from_subscription(router_id).router.router_fqdn
         device = self.get_device_by_name(router_name)
 
-        # Get the existing :term:`LAG` interfaces for the device
+        # Get the existing LAG interfaces for the device
         lag_interface_names = [
             interface["name"] for interface in self.netbox.dcim.interfaces.filter(device=device.name, type="lag")
         ]
 
-        # Generate all feasible LAGs
-        all_feasible_lags = [f"lag-{i}" for i in FEASIBLE_IP_TRUNK_LAG_RANGE]
+        # Generate all feasible LAGs in the specified range
+        all_feasible_lags = [f"lag-{i}" for i in lag_range]
 
         # Return available LAGs not assigned to the device
         return [lag for lag in all_feasible_lags if lag not in lag_interface_names]
 
+    def get_available_lags(self, router_id: UUID) -> list[str]:
+        """Return all available :term:`LAG` not assigned to a device."""
+        return self.get_available_lags_in_range(router_id, FEASIBLE_IP_TRUNK_LAG_RANGE)
+
+    def get_available_services_lags(self, router_id: UUID) -> list[str]:
+        """Return all available Edge port LAGs not assigned to a device."""
+        return self.get_available_lags_in_range(router_id, range(20, 51))
+
     @staticmethod
     def calculate_speed_bits_per_sec(speed: str) -> int:
         """Extract the numeric part from the speed."""
diff --git a/gso/utils/device_info.py b/gso/utils/device_info.py
index 800d5677..6ee6004c 100644
--- a/gso/utils/device_info.py
+++ b/gso/utils/device_info.py
@@ -45,8 +45,8 @@ class TierInfo:
         return getattr(self, name)
 
 
-# The range includes values from 1 to 10 (11 is not included)
 FEASIBLE_IP_TRUNK_LAG_RANGE = range(1, 11)
+FEASIBLE_SERVICES_LAG_RANGE = range(20, 51)
 
 # Define default values
 ROUTER_ROLE = {"name": "router", "slug": "router"}
diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index 4a78b989..00e420c0 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -11,9 +11,10 @@ from gso.products.product_blocks.router import RouterRole
 from gso.products.product_types.router import Router
 from gso.services import subscriptions
 from gso.services.netbox_client import NetboxClient
-from gso.utils.shared_enums import Vendor
 from gso.utils.types.interfaces import PhysicalPortCapacity
 from gso.utils.types.ip_address import IPv4AddressType
+from gso.services.partners import get_all_partners
+from gso.utils.shared_enums import Vendor
 
 if TYPE_CHECKING:
     from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock
@@ -75,6 +76,18 @@ def available_lags_choices(router_id: UUID) -> Choice | None:
     return Choice("ae iface", zip(side_a_ae_iface_list, side_a_ae_iface_list, strict=True))  # type: ignore[arg-type]
 
 
+def available_service_lags_choices(router_id: UUID) -> Choice | None:
+    """Return a list of available lags for a given router for services.
+
+    For Nokia routers, return a list of available lags.
+    For Juniper routers, return ``None``.
+    """
+    if get_router_vendor(router_id) != Vendor.NOKIA:
+        return None
+    side_a_ae_iface_list = NetboxClient().get_available_services_lags(router_id)
+    return Choice("ae iface", zip(side_a_ae_iface_list, side_a_ae_iface_list, strict=True))  # type: ignore[arg-type]
+
+
 def get_router_vendor(router_id: UUID) -> Vendor:
     """Retrieve the vendor of a router.
 
@@ -185,3 +198,22 @@ def active_switch_selector() -> Choice:
     }
 
     return Choice("Select a switch", zip(switch_subscriptions.keys(), switch_subscriptions.items(), strict=True))  # type: ignore[arg-type]
+
+
+def partner_choice() -> Choice:
+    """Return a Choice object containing a list of available partners."""
+    partners = {partner["partner_id"]: partner["name"] for partner in get_all_partners()}
+
+    return Choice("Select a partner", zip(partners.values(), partners.items(), strict=True))  # type: ignore[arg-type]
+
+
+def validate_edge_port_number_of_members_based_on_lacp(*, number_of_members: int, enable_lacp: bool) -> None:
+    """Validate the number of edge port members based on the LACP setting.
+
+    :param number_of_members: The number of members to validate.
+    :param enable_lacp: Whether LACP is enabled or not.
+    :raises ValueError: If the number of members is greater than 1 and LACP is disabled.
+    """
+    if number_of_members > 1 and not enable_lacp:
+        err_msg = "Number of members must be 1 if LACP is disabled"
+        raise ValueError(err_msg)
diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py
index d94174f2..ecddb6ff 100644
--- a/gso/workflows/__init__.py
+++ b/gso/workflows/__init__.py
@@ -78,3 +78,10 @@ LazyWorkflowInstance("gso.workflows.tasks.create_partners", "task_create_partner
 LazyWorkflowInstance("gso.workflows.tasks.modify_partners", "task_modify_partners")
 LazyWorkflowInstance("gso.workflows.tasks.delete_partners", "task_delete_partners")
 LazyWorkflowInstance("gso.workflows.tasks.clean_old_tasks", "task_clean_old_tasks")
+
+
+#  Edge port workflows
+LazyWorkflowInstance("gso.workflows.edge_port.create_edge_port", "create_edge_port")
+LazyWorkflowInstance("gso.workflows.edge_port.modify_edge_port", "modify_edge_port")
+LazyWorkflowInstance("gso.workflows.edge_port.terminate_edge_port", "terminate_edge_port")
+LazyWorkflowInstance("gso.workflows.edge_port.validate_edge_port", "validate_edge_port")
diff --git a/gso/workflows/edge_port/__init__.py b/gso/workflows/edge_port/__init__.py
new file mode 100644
index 00000000..5e496dae
--- /dev/null
+++ b/gso/workflows/edge_port/__init__.py
@@ -0,0 +1 @@
+"""All workflows that can be executed on Edge port."""
diff --git a/gso/workflows/edge_port/create_edge_port.py b/gso/workflows/edge_port/create_edge_port.py
new file mode 100644
index 00000000..ddca7d48
--- /dev/null
+++ b/gso/workflows/edge_port/create_edge_port.py
@@ -0,0 +1,254 @@
+"""A creation workflow for adding a new edge port to the network."""
+
+from typing import Annotated, Any, Self
+from uuid import uuid4
+
+from annotated_types import Len
+from orchestrator import step, workflow
+from orchestrator.forms import FormPage
+from orchestrator.forms.validators import Choice
+from orchestrator.targets import Target
+from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr
+from orchestrator.workflow import StepList, begin, done
+from orchestrator.workflows.steps import resync, set_status, store_process_subscription
+from orchestrator.workflows.utils import wrap_create_initial_input_form
+from pydantic import AfterValidator, ConfigDict, model_validator
+from pydantic_forms.validators import validate_unique_list
+from pynetbox.models.dcim import Interfaces
+
+from gso.products.product_blocks.edge_port import EdgePortInterfaceBlockInactive, EdgePortType, EncapsulationType
+from gso.products.product_blocks.iptrunk import PhysicalPortCapacity
+from gso.products.product_blocks.router import RouterRole
+from gso.products.product_types.edge_port import EdgePortInactive, EdgePortProvisioning
+from gso.products.product_types.router import Router
+from gso.services import lso_client, subscriptions
+from gso.services.lso_client import lso_interaction
+from gso.services.netbox_client import NetboxClient
+from gso.utils.helpers import (
+    LAGMember,
+    available_interfaces_choices,
+    available_service_lags_choices,
+    partner_choice,
+    validate_edge_port_number_of_members_based_on_lacp,
+)
+from gso.utils.types import TTNumber
+
+
+def initial_input_form_generator(product_name: str) -> FormGenerator:
+    """Gather information to create a new Edge Port."""
+    routers = {}
+    for router in subscriptions.get_active_subscriptions_by_field_and_value("router_role", RouterRole.PE):
+        routers[str(router.subscription_id)] = router.description
+    router_enum = Choice("Select a router", zip(routers.keys(), routers.items(), strict=True))  # type: ignore[arg-type]
+
+    class CreateEdgePortForm(FormPage):
+        model_config = ConfigDict(title=product_name)
+
+        tt_number: TTNumber
+        node: router_enum  # type: ignore[valid-type]
+        partner: partner_choice()  # type: ignore[valid-type]
+        service_type: EdgePortType
+        enable_lacp: bool
+        speed: PhysicalPortCapacity
+        encapsulation: EncapsulationType = EncapsulationType.DOT1Q
+        number_of_members: int
+        minimum_links: int
+        mac_address: str | None = None
+        ignore_if_down: bool = False
+        geant_ga_id: str | None = None
+
+        @model_validator(mode="after")
+        def validate_number_of_members(self) -> Self:
+            validate_edge_port_number_of_members_based_on_lacp(
+                enable_lacp=self.enable_lacp, number_of_members=self.number_of_members
+            )
+            return self
+
+    initial_user_input = yield CreateEdgePortForm
+
+    class EdgePortLAGMember(LAGMember):
+        interface_name: available_interfaces_choices(  # type: ignore[valid-type]
+            initial_user_input.node, initial_user_input.speed
+        )
+
+    lag_ae_members = Annotated[
+        list[EdgePortLAGMember],
+        AfterValidator(validate_unique_list),
+        Len(
+            min_length=initial_user_input.number_of_members,
+            max_length=initial_user_input.number_of_members,
+        ),
+    ]
+
+    class SelectInterfaceForm(FormPage):
+        model_config = ConfigDict(title="Select Interfaces")
+
+        name: available_service_lags_choices(initial_user_input.node)  # type: ignore[valid-type]
+        description: str | None = None
+        ae_members: lag_ae_members
+
+    interface_form_input_data = yield SelectInterfaceForm
+    return initial_user_input.model_dump() | interface_form_input_data.model_dump()
+
+
+@step("Create subscription")
+def create_subscription(product: UUIDstr, partner: UUIDstr) -> State:
+    """Create a new subscription object."""
+    subscription = EdgePortInactive.from_product_id(product, partner)
+
+    return {
+        "subscription": subscription,
+        "subscription_id": subscription.subscription_id,
+    }
+
+
+@step("Initialize subscription")
+def initialize_subscription(
+    subscription: EdgePortInactive,
+    node: UUIDstr,
+    service_type: EdgePortType,
+    speed: PhysicalPortCapacity,
+    encapsulation: EncapsulationType,
+    name: str,
+    minimum_links: int,
+    geant_ga_id: str | None,
+    mac_address: str | None,
+    partner: str,
+    enable_lacp: bool,  # noqa: FBT001
+    ignore_if_down: bool,  # noqa: FBT001
+    ae_members: list[dict[str, Any]],
+    description: str | None = None,
+) -> State:
+    """Initialise the subscription object in the service database."""
+    router = Router.from_subscription(node).router
+    subscription.edge_port.edge_port_node = router
+    subscription.edge_port.edge_port_type = service_type
+    subscription.edge_port.edge_port_enable_lacp = enable_lacp
+    subscription.edge_port.edge_port_member_speed = speed
+    subscription.edge_port.edge_port_encapsulation = encapsulation
+    subscription.edge_port.edge_port_name = name
+    subscription.edge_port.edge_port_minimum_links = minimum_links
+    subscription.edge_port.edge_port_ignore_if_down = ignore_if_down
+    subscription.edge_port.edge_port_geant_ga_id = geant_ga_id
+    subscription.edge_port.edge_port_mac_address = mac_address
+    subscription.description = f"Edge Port {name} on {router.router_fqdn}, {partner}, {geant_ga_id or ""}"
+    subscription.edge_port.edge_port_description = description
+    for member in ae_members:
+        subscription.edge_port.edge_port_ae_members.append(
+            EdgePortInterfaceBlockInactive.new(subscription_id=uuid4(), **member),
+        )
+    subscription = EdgePortProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING)
+
+    return {"subscription": subscription}
+
+
+@step("Reserve interfaces in NetBox")
+def reserve_interfaces_in_netbox(subscription: EdgePortProvisioning) -> State:
+    """Create the :term:`LAG` interfaces in NetBox and attach the lag interfaces to the physical interfaces."""
+    nbclient = NetboxClient()
+    edge_port = subscription.edge_port
+    # Create :term:`LAG` interfaces
+    lag_interface: Interfaces = nbclient.create_interface(
+        iface_name=edge_port.edge_port_name,
+        interface_type="lag",
+        device_name=edge_port.edge_port_node.router_fqdn,
+        description=str(subscription.subscription_id),
+        enabled=True,
+    )
+    # Attach physical interfaces to :term:`LAG`
+    # Update interface description to subscription ID
+    # Reserve interfaces
+    for interface in edge_port.edge_port_ae_members:
+        nbclient.attach_interface_to_lag(
+            device_name=edge_port.edge_port_node.router_fqdn,
+            lag_name=lag_interface.name,
+            iface_name=interface.interface_name,
+            description=str(subscription.subscription_id),
+        )
+        nbclient.reserve_interface(
+            device_name=edge_port.edge_port_node.router_fqdn,
+            iface_name=interface.interface_name,
+        )
+    return {
+        "subscription": subscription,
+    }
+
+
+@step("Allocate interfaces in NetBox")
+def allocate_interfaces_in_netbox(subscription: EdgePortProvisioning) -> None:
+    """Allocate the interfaces in NetBox."""
+    for interface in subscription.edge_port.edge_port_ae_members:
+        fqdn = subscription.edge_port.edge_port_node.router_fqdn
+        iface_name = interface.interface_name
+        if not fqdn or not iface_name:
+            msg = f"FQDN and/or interface name missing in subscription {interface.owner_subscription_id}"
+            raise ValueError(msg)
+
+        NetboxClient().allocate_interface(device_name=fqdn, iface_name=iface_name)
+
+
+@step("[DRY RUN] Create edge port")
+def create_edge_port_dry(
+    subscription: dict[str, Any], callback_route: str, tt_number: str, process_id: UUIDstr
+) -> None:
+    """Create a new edge port in the network as a dry run."""
+    extra_vars = {
+        "dry_run": True,
+        "subscription": subscription,
+        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Create Edge Port",
+        "verb": "create",
+    }
+
+    lso_client.execute_playbook(
+        playbook_name="edge_port.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars=extra_vars,
+    )
+
+
+@step("[FOR REAL] Create edge port")
+def create_edge_port_real(
+    subscription: dict[str, Any], callback_route: str, tt_number: str, process_id: UUIDstr
+) -> None:
+    """Create a new edge port in the network for real."""
+    extra_vars = {
+        "dry_run": False,
+        "subscription": subscription,
+        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Create Edge Port",
+        "verb": "create",
+    }
+
+    lso_client.execute_playbook(
+        playbook_name="edge_port.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars=extra_vars,
+    )
+
+
+@workflow(
+    "Create Edge Port",
+    initial_input_form=wrap_create_initial_input_form(initial_input_form_generator),
+    target=Target.CREATE,
+)
+def create_edge_port() -> StepList:
+    """Create a new edge port in the network.
+
+    * Create and initialise the subscription object in the service database
+    * Deploy configuration on the new edge port, first as a dry run
+    * allocate LAG and LAG members in the Netbox.
+    """
+    return (
+        begin
+        >> create_subscription
+        >> store_process_subscription(Target.CREATE)
+        >> initialize_subscription
+        >> reserve_interfaces_in_netbox
+        >> lso_interaction(create_edge_port_dry)
+        >> lso_interaction(create_edge_port_real)
+        >> allocate_interfaces_in_netbox
+        >> set_status(SubscriptionLifecycle.ACTIVE)
+        >> resync
+        >> done
+    )
diff --git a/gso/workflows/edge_port/modify_edge_port.py b/gso/workflows/edge_port/modify_edge_port.py
new file mode 100644
index 00000000..cb7bcc9b
--- /dev/null
+++ b/gso/workflows/edge_port/modify_edge_port.py
@@ -0,0 +1,294 @@
+"""Modify an existing edge port subscription."""
+
+from typing import Annotated, Any, Self
+from uuid import uuid4
+
+from annotated_types import Len
+from orchestrator import workflow
+from orchestrator.forms import FormPage
+from orchestrator.targets import Target
+from orchestrator.workflow import StepList, begin, conditional, done, step
+from orchestrator.workflows.steps import resync, store_process_subscription, unsync
+from orchestrator.workflows.utils import wrap_modify_initial_input_form
+from pydantic import AfterValidator, ConfigDict, model_validator
+from pydantic_forms.types import FormGenerator, UUIDstr
+from pydantic_forms.validators import ReadOnlyField, validate_unique_list
+
+from gso.products.product_blocks.edge_port import (
+    EdgePortInterfaceBlock,
+    EncapsulationType,
+)
+from gso.products.product_blocks.iptrunk import PhysicalPortCapacity
+from gso.products.product_types.edge_port import EdgePort
+from gso.services.lso_client import execute_playbook, lso_interaction
+from gso.services.netbox_client import NetboxClient
+from gso.services.partners import get_partner_by_id
+from gso.utils.helpers import (
+    LAGMember,
+    available_interfaces_choices,
+    available_interfaces_choices_including_current_members,
+    validate_edge_port_number_of_members_based_on_lacp,
+)
+from gso.utils.types import TTNumber
+
+
+def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
+    """Gather input from the operator on what to change about the selected edge port subscription."""
+    subscription = EdgePort.from_subscription(subscription_id)
+
+    class ModifyEdgePortForm(FormPage):
+        model_config = ConfigDict(title="Modify Edge Port")
+
+        tt_number: TTNumber
+        enable_lacp: bool = subscription.edge_port.edge_port_enable_lacp
+        member_speed: PhysicalPortCapacity = subscription.edge_port.edge_port_member_speed
+        encapsulation: EncapsulationType = subscription.edge_port.edge_port_encapsulation
+        number_of_members: int = len(subscription.edge_port.edge_port_ae_members)
+        minimum_links: int | None = subscription.edge_port.edge_port_minimum_links or None
+        mac_address: str | None = subscription.edge_port.edge_port_mac_address or None
+        ignore_if_down: bool = subscription.edge_port.edge_port_ignore_if_down
+        geant_ga_id: str | None = subscription.edge_port.edge_port_geant_ga_id or None
+
+        @model_validator(mode="after")
+        def validate_number_of_members(self) -> Self:
+            validate_edge_port_number_of_members_based_on_lacp(
+                enable_lacp=self.enable_lacp, number_of_members=self.number_of_members
+            )
+            return self
+
+    user_input = yield ModifyEdgePortForm
+
+    class EdgePortLAGMember(LAGMember):
+        interface_name: (  # type: ignore[valid-type]
+            available_interfaces_choices_including_current_members(
+                subscription.edge_port.edge_port_node.owner_subscription_id,
+                user_input.member_speed,
+                subscription.edge_port.edge_port_ae_members,
+            )
+            if user_input.member_speed == subscription.edge_port.edge_port_member_speed
+            else (
+                available_interfaces_choices(
+                    subscription.edge_port.edge_port_node.owner_subscription_id, user_input.member_speed
+                )
+            )
+        )
+
+    lag_ae_members = Annotated[
+        list[EdgePortLAGMember],
+        AfterValidator(validate_unique_list),
+        Len(
+            min_length=user_input.number_of_members,
+            max_length=user_input.number_of_members,
+        ),
+    ]
+
+    existing_lag_ae_members = [
+        EdgePortLAGMember(
+            interface_name=iface.interface_name,
+            interface_description=iface.interface_description,
+        )
+        for iface in subscription.edge_port.edge_port_ae_members
+    ]
+
+    class ModifyEdgePortInterfaceForm(FormPage):
+        model_config = ConfigDict(title="Modify Edge Port Interface")
+
+        name: ReadOnlyField(subscription.edge_port.edge_port_name, default_type=str)  # type: ignore[valid-type]
+        description: str | None = subscription.edge_port.edge_port_description or None
+        ae_members: lag_ae_members = (
+            existing_lag_ae_members if user_input.member_speed == subscription.edge_port.edge_port_member_speed else []
+        )
+
+    interface_form_input = yield ModifyEdgePortInterfaceForm
+
+    capacity_has_changed = (
+        user_input.member_speed != subscription.edge_port.edge_port_member_speed
+        or user_input.number_of_members != len(subscription.edge_port.edge_port_ae_members)
+        or any(
+            old_interface.interface_name
+            not in [new_interface.interface_name for new_interface in interface_form_input.ae_members]
+            for old_interface in subscription.edge_port.edge_port_ae_members
+        )
+        or len(subscription.edge_port.edge_port_ae_members) != len(interface_form_input.ae_members)
+    )
+    return user_input.model_dump() | interface_form_input.model_dump() | {"capacity_has_changed": capacity_has_changed}
+
+
+@step("Modify edge port subscription.")
+def modify_edge_port_subscription(
+    subscription: EdgePort,
+    member_speed: PhysicalPortCapacity,
+    encapsulation: EncapsulationType,
+    minimum_links: int,
+    mac_address: str | None,
+    geant_ga_id: str | None,
+    enable_lacp: bool,  # noqa: FBT001
+    ae_members: list[dict[str, str]],
+    ignore_if_down: bool,  # noqa: FBT001
+    description: str | None = None,
+) -> dict[str, Any]:
+    """Modify the edge port subscription with the given parameters."""
+    previous_ae_members = [
+        {
+            "interface_name": member.interface_name,
+            "interface_description": member.interface_description,
+        }
+        for member in subscription.edge_port.edge_port_ae_members
+    ]
+    removed_ae_members = [member for member in previous_ae_members if member not in ae_members]
+    subscription.edge_port.edge_port_enable_lacp = enable_lacp
+    subscription.edge_port.edge_port_member_speed = member_speed
+    subscription.edge_port.edge_port_encapsulation = encapsulation
+    subscription.edge_port.edge_port_minimum_links = minimum_links
+    subscription.edge_port.edge_port_mac_address = mac_address
+    subscription.edge_port.edge_port_ignore_if_down = ignore_if_down
+    subscription.edge_port.edge_port_geant_ga_id = geant_ga_id
+    subscription.edge_port.edge_port_description = description
+    subscription.description = (
+        f"Edge Port {subscription.edge_port.edge_port_name} on"
+        f" {subscription.edge_port.edge_port_node.router_fqdn},"
+        f" {get_partner_by_id(subscription.customer_id).name}, {geant_ga_id or ""}"
+    )
+    subscription.edge_port.edge_port_ae_members.clear()
+    for member in ae_members:
+        subscription.edge_port.edge_port_ae_members.append(
+            EdgePortInterfaceBlock.new(subscription_id=uuid4(), **member),
+        )
+    subscription.save()
+
+    return {
+        "subscription": subscription,
+        "removed_ae_members": removed_ae_members,
+        "previous_ae_members": previous_ae_members,
+    }
+
+
+@step("Update interfaces in NetBox")
+def update_interfaces_in_netbox(
+    subscription: EdgePort,
+    removed_ae_members: list[dict],
+    previous_ae_members: list[dict],
+) -> dict[str, Any]:
+    """Update the interfaces in NetBox."""
+    nbclient = NetboxClient()
+    # Free removed interfaces
+    for member in removed_ae_members:
+        nbclient.free_interface(subscription.edge_port.edge_port_node.router_fqdn, member["interface_name"])
+    # Attach physical interfaces to :term:`LAG`
+    # Update interface description to subscription ID
+    # Reserve interfaces
+    for member in subscription.edge_port.edge_port_ae_members:  # type: ignore[assignment]
+        if any(prev_member["interface_name"] == member.interface_name for prev_member in previous_ae_members):
+            continue
+        nbclient.attach_interface_to_lag(
+            device_name=subscription.edge_port.edge_port_node.router_fqdn,
+            lag_name=subscription.edge_port.edge_port_name,
+            iface_name=member.interface_name,
+            description=str(subscription.subscription_id),
+        )
+        nbclient.reserve_interface(subscription.edge_port.edge_port_node.router_fqdn, member.interface_name)
+
+    return {"subscription": subscription}
+
+
+@step("[DRY RUN] Update edge port configuration.")
+def update_edge_port_dry(
+    subscription: dict[str, Any],
+    process_id: UUIDstr,
+    tt_number: str,
+    callback_route: str,
+    removed_ae_members: list[dict],
+) -> dict[str, Any]:
+    """Perform a dry run of updating the edge port configuration."""
+    extra_vars = {
+        "subscription": subscription,
+        "dry_run": True,
+        "verb": "update",
+        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
+        f"- Update Edge Port {subscription["edge_port"]["edge_port_name"]}"
+        f" on {subscription["edge_port"]["edge_port_node"]["router_fqdn"]}",
+        "removed_ae_members": removed_ae_members,
+    }
+
+    execute_playbook(
+        playbook_name="edge_ports.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars=extra_vars,
+    )
+
+    return {"subscription": subscription}
+
+
+@step("[FOR REAL] Update edge port configuration.")
+def update_edge_port_real(
+    subscription: dict[str, Any],
+    process_id: UUIDstr,
+    tt_number: str,
+    callback_route: str,
+    removed_ae_members: list[str],
+) -> dict[str, Any]:
+    """Update the edge port configuration."""
+    extra_vars = {
+        "subscription": subscription,
+        "dry_run": False,
+        "verb": "update",
+        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} "
+        f"- Update Edge Port {subscription["edge_port"]["edge_port_name"]}"
+        f" on {subscription["edge_port"]["edge_port_node"]["router_fqdn"]}",
+        "removed_ae_members": removed_ae_members,
+    }
+
+    execute_playbook(
+        playbook_name="edge_ports.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars=extra_vars,
+    )
+
+    return {"subscription": subscription}
+
+
+@step("Allocate/Deallocate interfaces in NetBox")
+def allocate_interfaces_in_netbox(subscription: EdgePort, previous_ae_members: list[dict]) -> None:
+    """Allocate the new interfaces in NetBox and detach the old ones from the LAG."""
+    nbclient = NetboxClient()
+    for member in subscription.edge_port.edge_port_ae_members:
+        if any(member.interface_name == prev_member["interface_name"] for prev_member in previous_ae_members):
+            continue
+        nbclient.allocate_interface(
+            device_name=subscription.edge_port.edge_port_node.router_fqdn,
+            iface_name=member.interface_name,
+        )
+
+    # detach the old interfaces from lag
+    nbclient.detach_interfaces_from_lag(
+        device_name=subscription.edge_port.edge_port_node.router_fqdn, lag_name=subscription.edge_port.edge_port_name
+    )
+
+
+@workflow(
+    "Modify Edge Port",
+    initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
+    target=Target.MODIFY,
+)
+def modify_edge_port() -> StepList:
+    """Modify a new edge port in the network.
+
+    * Modify the subscription object in the service database
+    * Modify configuration on the new edge port, first as a dry run
+    * Change LAG and LAG members in the Netbox.
+    """
+    capacity_has_changed = conditional(lambda state: state["capacity_has_changed"])
+    return (
+        begin
+        >> store_process_subscription(Target.MODIFY)
+        >> unsync
+        >> modify_edge_port_subscription
+        >> capacity_has_changed(update_interfaces_in_netbox)
+        >> capacity_has_changed(lso_interaction(update_edge_port_dry))
+        >> capacity_has_changed(lso_interaction(update_edge_port_real))
+        >> capacity_has_changed(allocate_interfaces_in_netbox)
+        >> resync
+        >> done
+    )
diff --git a/gso/workflows/edge_port/terminate_edge_port.py b/gso/workflows/edge_port/terminate_edge_port.py
new file mode 100644
index 00000000..53569c1e
--- /dev/null
+++ b/gso/workflows/edge_port/terminate_edge_port.py
@@ -0,0 +1,99 @@
+"""Terminate an edge port in the network."""
+
+from typing import Any
+
+from orchestrator import workflow
+from orchestrator.forms import FormPage
+from orchestrator.targets import Target
+from orchestrator.types import SubscriptionLifecycle
+from orchestrator.workflow import StepList, begin, 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 gso.products.product_types.edge_port import EdgePort
+from gso.services.lso_client import execute_playbook, lso_interaction
+from gso.services.netbox_client import NetboxClient
+from gso.utils.types import TTNumber
+
+
+def initial_input_form_generator() -> FormGenerator:
+    """Let the operator decide whether to delete configuration on the router, and clear up :term:`IPAM` resources."""
+
+    class TerminateForm(FormPage):
+        tt_number: TTNumber
+
+    user_input = yield TerminateForm
+    return user_input.model_dump()
+
+
+@step("[DRY RUN] Remove Edge Port")
+def remove_edge_port_dry(
+    subscription: dict[str, Any], tt_number: str, process_id: UUIDstr, callback_route: str
+) -> dict[str, Any]:
+    """Remove an edge port from the network."""
+    extra_vars = {
+        "subscription": subscription,
+        "dry_run": True,
+        "verb": "terminate",
+        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Delete Edge Port",
+    }
+
+    execute_playbook(
+        playbook_name="edge_port.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars=extra_vars,
+    )
+    return {"subscription": subscription}
+
+
+@step("[FOR REAL] Remove Edge Port")
+def remove_edge_port_real(
+    subscription: dict[str, Any], tt_number: str, process_id: UUIDstr, callback_route: str
+) -> None:
+    """Remove an edge port from the network."""
+    extra_vars = {
+        "subscription": subscription,
+        "dry_run": False,
+        "verb": "terminate",
+        "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Delete Edge Port",
+    }
+
+    execute_playbook(
+        playbook_name="edge_port.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars=extra_vars,
+    )
+
+
+@step("Netbox Clean Up")
+def netbox_clean_up(subscription: EdgePort) -> None:
+    """Update Netbox to remove the edge port LAG interface and all the LAG members."""
+    nbclient = NetboxClient()
+
+    for member in subscription.edge_port.edge_port_ae_members:
+        nbclient.free_interface(subscription.edge_port.edge_port_node.router_fqdn, member.interface_name)
+
+    nbclient.delete_interface(subscription.edge_port.edge_port_node.router_fqdn, subscription.edge_port.edge_port_name)
+
+
+@workflow(
+    "Terminate Edge Port",
+    initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
+    target=Target.TERMINATE,
+)
+def terminate_edge_port() -> StepList:
+    """Terminate a new edge port in the network."""
+    return (
+        begin
+        >> store_process_subscription(Target.TERMINATE)
+        >> unsync
+        >> lso_interaction(remove_edge_port_dry)
+        >> lso_interaction(remove_edge_port_real)
+        >> netbox_clean_up
+        >> set_status(SubscriptionLifecycle.TERMINATED)
+        >> resync
+        >> done
+    )
diff --git a/gso/workflows/edge_port/validate_edge_port.py b/gso/workflows/edge_port/validate_edge_port.py
new file mode 100644
index 00000000..60611acc
--- /dev/null
+++ b/gso/workflows/edge_port/validate_edge_port.py
@@ -0,0 +1,91 @@
+"""Workflow for validating an existing Edge port subscription."""
+
+from typing import Any
+
+from orchestrator.targets import Target
+from orchestrator.types import State, UUIDstr
+from orchestrator.utils.errors import ProcessFailureError
+from orchestrator.workflow import StepList, begin, done, step, workflow
+from orchestrator.workflows.steps import resync, store_process_subscription
+from orchestrator.workflows.utils import wrap_modify_initial_input_form
+
+from gso.products.product_types.edge_port import EdgePort
+from gso.services.lso_client import execute_playbook
+from gso.services.netbox_client import NetboxClient
+
+
+@step("Prepare required keys in state")
+def prepare_state(subscription_id: UUIDstr) -> State:
+    """Add required keys to the state for the workflow to run successfully."""
+    edge_port = EdgePort.from_subscription(subscription_id)
+
+    return {"subscription": edge_port}
+
+
+@step("Verify NetBox entries")
+def verify_netbox_entries(subscription: EdgePort) -> None:
+    """Validate required entries for an edge port in NetBox."""
+    nbclient = NetboxClient()
+    netbox_errors = []
+
+    #  Raises en exception when not found.
+    lag = nbclient.get_interface_by_name_and_device(
+        subscription.edge_port.edge_port_name, subscription.edge_port.edge_port_node.router_fqdn
+    )
+    if lag.description != str(subscription.subscription_id):
+        netbox_errors.append(
+            f"Incorrect description for '{lag}', expected "
+            f"'{subscription.subscription_id}' but got '{lag.description}'"
+        )
+    if not lag.enabled:
+        netbox_errors.append(f"NetBox interface '{lag}' is not enabled.")
+    for member in subscription.edge_port.edge_port_ae_members:
+        interface = nbclient.get_interface_by_name_and_device(
+            member.interface_name, subscription.edge_port.edge_port_node.router_fqdn
+        )
+        if interface.description != str(subscription.subscription_id):
+            netbox_errors.append(
+                f"Incorrect description for '{member.interface_name}', expected "
+                f"'{subscription.subscription_id}' but got '{interface.description}'"
+            )
+        if not interface.enabled:
+            netbox_errors.append(f"NetBox interface '{member.interface_name}' is not enabled.")
+
+    if netbox_errors:
+        raise ProcessFailureError(message="NetBox misconfiguration(s) found", details=str(netbox_errors))
+
+
+@step("Check base config for drift")
+def verify_base_config(subscription: dict[str, Any], callback_route: str) -> None:
+    """Workflow step for running a playbook that checks whether base config has drifted."""
+    execute_playbook(
+        playbook_name="edge_port.yaml",
+        callback_route=callback_route,
+        inventory=subscription["edge_port"]["edge_port_node"]["router_fqdn"],
+        extra_vars={
+            "dry_run": True,
+            "subscription": subscription,
+            "verb": "create",
+            "is_verification_workflow": "true",
+        },
+    )
+
+
+@workflow(
+    "Validate Edge Port Configuration", target=Target.SYSTEM, initial_input_form=wrap_modify_initial_input_form(None)
+)
+def validate_edge_port() -> StepList:
+    """Validate an existing, active Edge port subscription.
+
+    * Check correct configuration of interfaces in NetBox.
+    * Verify create Edge port configuration.
+    """
+    return (
+        begin
+        >> store_process_subscription(Target.SYSTEM)
+        >> prepare_state
+        >> verify_netbox_entries
+        >> verify_base_config
+        >> resync
+        >> done
+    )
-- 
GitLab