From e686ef94b2d01c2e093fc8c27b4c5a1b09a6c1ed Mon Sep 17 00:00:00 2001
From: Saket Agrahari <saket.agrahari@geant.org>
Date: Mon, 12 May 2025 07:26:36 +0100
Subject: [PATCH 1/5] initial changes for iptrunk version

---
 ...7431c9ef_add_config_version_to_ip_trunk.py | 41 +++++++++++++++++++
 gso/products/product_blocks/iptrunk.py        |  7 +++-
 .../iptrunk/create_imported_iptrunk.py        | 15 ++++---
 gso/workflows/iptrunk/create_iptrunk.py       | 22 ++++++----
 .../iptrunk/test_create_imported_iptrunk.py   |  1 +
 test/workflows/iptrunk/test_create_iptrunk.py |  1 +
 6 files changed, 70 insertions(+), 17 deletions(-)
 create mode 100644 gso/migrations/versions/2025-05-12_54477431c9ef_add_config_version_to_ip_trunk.py

diff --git a/gso/migrations/versions/2025-05-12_54477431c9ef_add_config_version_to_ip_trunk.py b/gso/migrations/versions/2025-05-12_54477431c9ef_add_config_version_to_ip_trunk.py
new file mode 100644
index 000000000..401717874
--- /dev/null
+++ b/gso/migrations/versions/2025-05-12_54477431c9ef_add_config_version_to_ip_trunk.py
@@ -0,0 +1,41 @@
+"""add config version to ip trunk.
+
+Revision ID: 54477431c9ef
+Revises: 465008ed496e
+Create Date: 2025-05-12 04:46:47.410668
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '54477431c9ef'
+down_revision = '465008ed496e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    conn = op.get_bind()
+    conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('iptrunk_config_version', 'adding option of service version controlled rollout') RETURNING resource_types.resource_type_id
+    """))
+    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 ('IptrunkBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_config_version')))
+    """))
+
+
+def downgrade() -> None:
+    conn = op.get_bind()
+    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 ('IptrunkBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_config_version'))
+    """))
+    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 ('IptrunkBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_config_version'))
+    """))
+    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 ('iptrunk_config_version'))
+    """))
+    conn.execute(sa.text("""
+DELETE FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_config_version')
+    """))
diff --git a/gso/products/product_blocks/iptrunk.py b/gso/products/product_blocks/iptrunk.py
index 8bfc0056a..1c6cb4a66 100644
--- a/gso/products/product_blocks/iptrunk.py
+++ b/gso/products/product_blocks/iptrunk.py
@@ -4,8 +4,6 @@ import ipaddress
 from typing import Annotated
 
 from annotated_types import Len
-from orchestrator.domain.base import ProductBlockModel, T
-from orchestrator.types import SubscriptionLifecycle
 from pydantic import AfterValidator
 from pydantic_forms.types import strEnum
 from pydantic_forms.validators import validate_unique_list
@@ -17,6 +15,8 @@ from gso.products.product_blocks.router import (
     RouterBlockProvisioning,
 )
 from gso.utils.types.interfaces import LAGMemberList, PhysicalPortCapacity
+from orchestrator.domain.base import ProductBlockModel, T
+from orchestrator.types import SubscriptionLifecycle
 
 
 class IptrunkType(strEnum):
@@ -109,6 +109,7 @@ class IptrunkBlockInactive(
     iptrunk_ipv6_network: ipaddress.IPv6Network | None = None
     iptrunk_sides: IptrunkSides[IptrunkSideBlockInactive]
     iptrunk_description_suffix: str | None = None
+    iptrunk_config_version: str | None = None
 
 
 class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
@@ -124,6 +125,7 @@ class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLife
     iptrunk_ipv6_network: ipaddress.IPv6Network | None
     iptrunk_sides: IptrunkSides[IptrunkSideBlockProvisioning]  # type: ignore[assignment]
     iptrunk_description_suffix: str | None
+    iptrunk_config_version: str | None
 
 
 class IptrunkBlock(IptrunkBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
@@ -152,3 +154,4 @@ class IptrunkBlock(IptrunkBlockProvisioning, lifecycle=[SubscriptionLifecycle.AC
     iptrunk_ipv6_network: ipaddress.IPv6Network
     iptrunk_sides: IptrunkSides[IptrunkSideBlock]  # type: ignore[assignment]
     iptrunk_description_suffix: str | None
+    iptrunk_config_version: str | None
diff --git a/gso/workflows/iptrunk/create_imported_iptrunk.py b/gso/workflows/iptrunk/create_imported_iptrunk.py
index 4c45751b9..d65dee753 100644
--- a/gso/workflows/iptrunk/create_imported_iptrunk.py
+++ b/gso/workflows/iptrunk/create_imported_iptrunk.py
@@ -4,12 +4,6 @@ import ipaddress
 from typing import Annotated
 from uuid import uuid4
 
-from orchestrator import workflow
-from orchestrator.forms import SubmitFormPage
-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
 from pydantic import AfterValidator, ConfigDict
 from pydantic_forms.types import FormGenerator, State
 from pydantic_forms.validators import validate_unique_list
@@ -23,6 +17,12 @@ from gso.services.partners import get_partner_by_name
 from gso.utils.helpers import active_router_selector
 from gso.utils.types.geant_ids import IMPORTED_GA_ID, IMPORTED_GS_ID
 from gso.utils.types.interfaces import LAGMember, LAGMemberList, PhysicalPortCapacity
+from orchestrator import workflow
+from orchestrator.forms import SubmitFormPage
+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
 
 
 def initial_input_form_generator() -> FormGenerator:
@@ -39,6 +39,7 @@ def initial_input_form_generator() -> FormGenerator:
         iptrunk_minimum_links: int
         iptrunk_isis_metric: int
         iptrunk_description_suffix: str | None = None
+        iptrunk_config_version: str | None = None
 
         side_a_node_id: active_router_selector()  # type: ignore[valid-type]
         side_a_ae_iface: str
@@ -89,6 +90,7 @@ def initialize_subscription(
     side_b_ae_iface: str,
     side_b_ga_id: IMPORTED_GA_ID | None,
     side_b_ae_members: LAGMemberList,
+    iptrunk_config_version: str | None,
 ) -> State:
     """Take all input from the user, and store it in the database."""
     subscription.iptrunk.gs_id = gs_id
@@ -98,6 +100,7 @@ def initialize_subscription(
     subscription.iptrunk.iptrunk_isis_metric = iptrunk_isis_metric
     subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links
     subscription.iptrunk.iptrunk_description_suffix = iptrunk_description_suffix
+    subscription.iptrunk.iptrunk_config_version = iptrunk_config_version
 
     subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node = Router.from_subscription(side_a_node_id).router
     subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface = side_a_ae_iface
diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py
index 54bdb6d96..72f6a6c9a 100644
--- a/gso/workflows/iptrunk/create_iptrunk.py
+++ b/gso/workflows/iptrunk/create_iptrunk.py
@@ -31,15 +31,6 @@ from typing import Annotated
 from uuid import uuid4
 
 from annotated_types import Len
-from orchestrator.forms import FormPage
-from orchestrator.forms.validators import Choice, Label
-from orchestrator.targets import Target
-from orchestrator.types import SubscriptionLifecycle
-from orchestrator.utils.errors import ProcessFailureError
-from orchestrator.utils.json import json_dumps
-from orchestrator.workflow import StepList, begin, conditional, done, step, step_group, workflow
-from orchestrator.workflows.steps import resync, set_status, store_process_subscription
-from orchestrator.workflows.utils import wrap_create_initial_input_form
 from ping3 import ping
 from pydantic import ConfigDict
 from pydantic_forms.types import FormGenerator, State, UUIDstr
@@ -75,6 +66,15 @@ from gso.utils.types.netbox_router import NetboxEnabledRouter
 from gso.utils.types.tt_number import TTNumber
 from gso.utils.workflow_steps import prompt_sharepoint_checklist_url
 from gso.workflows.shared import create_summary_form
+from orchestrator.forms import FormPage
+from orchestrator.forms.validators import Choice, Label
+from orchestrator.targets import Target
+from orchestrator.types import SubscriptionLifecycle
+from orchestrator.utils.errors import ProcessFailureError
+from orchestrator.utils.json import json_dumps
+from orchestrator.workflow import StepList, begin, conditional, done, step, step_group, workflow
+from orchestrator.workflows.steps import resync, set_status, store_process_subscription
+from orchestrator.workflows.utils import wrap_create_initial_input_form
 
 
 def initial_input_form_generator(product_name: str) -> FormGenerator:
@@ -95,6 +95,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
         iptrunk_speed: PhysicalPortCapacity
         iptrunk_number_of_members: int
         iptrunk_description_suffix: str | None = None
+        iptrunk_config_version: str | None = None
 
     initial_user_input = yield CreateIptrunkForm
     recommended_minimum_links = calculate_recommended_minimum_links(
@@ -203,6 +204,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
         "iptrunk_description",
         "iptrunk_minimum_links",
         "iptrunk_description_suffix",
+        "iptrunk_config_version",
         "side_a_node",
         "side_a_ae_iface",
         "side_a_ae_members",
@@ -327,6 +329,7 @@ def initialize_subscription(
     iptrunk_speed: PhysicalPortCapacity,
     iptrunk_minimum_links: int,
     iptrunk_description_suffix: str | None,
+    iptrunk_config_version: str | None,
     side_a_node_id: str,
     side_a_ae_iface: str,
     side_a_ae_members: list[dict],
@@ -346,6 +349,7 @@ def initialize_subscription(
     subscription.iptrunk.iptrunk_isis_metric = oss_params.GENERAL.isis_high_metric
     subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links
     subscription.iptrunk.iptrunk_description_suffix = iptrunk_description_suffix
+    subscription.iptrunk.iptrunk_config_version = iptrunk_config_version
 
     subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node = side_a
     subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface = side_a_ae_iface
diff --git a/test/workflows/iptrunk/test_create_imported_iptrunk.py b/test/workflows/iptrunk/test_create_imported_iptrunk.py
index 7f83775bb..7add53de9 100644
--- a/test/workflows/iptrunk/test_create_imported_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_imported_iptrunk.py
@@ -23,6 +23,7 @@ def workflow_input_data(faker, router_subscription_factory):
         "iptrunk_minimum_links": 2,
         "iptrunk_isis_metric": 10000,
         "iptrunk_description_suffix": faker.word(),
+        "iptrunk_config_version": "1.0",
         "side_a_node_id": str(router_subscription_factory().subscription_id),
         "side_a_ae_iface": faker.nokia_lag_interface_name(),
         "side_a_ga_id": faker.imported_ga_id(),
diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py
index 6996e20a0..c18577249 100644
--- a/test/workflows/iptrunk/test_create_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_iptrunk.py
@@ -74,6 +74,7 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
         "iptrunk_speed": PhysicalPortCapacity.HUNDRED_GIGABIT_PER_SECOND,
         "iptrunk_number_of_members": 2,
         "iptrunk_description_suffix": faker.word(),
+        "iptrunk_config_version": "1.0"
     }
     create_ip_trunk_confirm_step = {"iptrunk_minimum_links": 1}
     create_ip_trunk_side_a_router_name = {"side_a_node_id": router_side_a}
-- 
GitLab


From 1362114b48575d38581fe3d6150dc79fa1f22619 Mon Sep 17 00:00:00 2001
From: Saket Agrahari <saket.agrahari@geant.org>
Date: Wed, 14 May 2025 08:44:04 +0100
Subject: [PATCH 2/5] adding config change and modify iptrunk

---
 gso/gso-services-config.json                  | 16 +++++++++++++
 gso/settings.py                               | 21 +++++++++++++++++
 .../iptrunk/modify_trunk_interface.py         | 23 +++++++++++--------
 3 files changed, 51 insertions(+), 9 deletions(-)
 create mode 100644 gso/gso-services-config.json

diff --git a/gso/gso-services-config.json b/gso/gso-services-config.json
new file mode 100644
index 000000000..dc53bbff5
--- /dev/null
+++ b/gso/gso-services-config.json
@@ -0,0 +1,16 @@
+{
+  "IP_TRUNK": {
+    "VERSION": {
+      "1.0": "Base Version",
+      "1.1":" Minor Upgrade"
+    },
+    "default_version": "1.0"
+  },
+  "GEANT_IP": {
+    "VERSION": {
+      "1.0": "Base Version",
+      "2.0": "Major Upgrade"
+    },
+    "default_version": "1.0"
+  }
+}
diff --git a/gso/settings.py b/gso/settings.py
index bf5dd53e1..2059a6023 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -250,7 +250,28 @@ def load_oss_params() -> OSSParams:
         return OSSParams(**json.loads(file.read()))
 
 
+class ServiceConfig(BaseSettings):
+    """Configurations for base gso service."""
+
+    version:  dict[str, str]
+    default_version: str
+
+
+class GSOServiceConfig(BaseSettings):
+    """Configuration for the GSO service."""
+    IP_TRUNK: ServiceConfig
+    GEANT_IP: ServiceConfig
+
+
+def load_gso_service_config() -> GSOServiceConfig:
+    """Load the GSO service configuration from the environment variable."""
+    """Look for ``GSO_SERVICE_CONFIG`` in the environment and load the parameters from that file."""
+    with Path(os.environ["GSO_SERVICE_CONFIG"]).open(encoding="utf-8") as file:
+        return GSOServiceConfig(**json.loads(file.read()))
+
+
 celery_settings = CelerySettings()
 
 if __name__ == "__main__":
     logger.debug(load_oss_params())
+    logger.debug(load_gso_service_config())
diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py
index 58277b0c5..793c1bc00 100644
--- a/gso/workflows/iptrunk/modify_trunk_interface.py
+++ b/gso/workflows/iptrunk/modify_trunk_interface.py
@@ -12,12 +12,6 @@ from typing import Annotated
 from uuid import UUID, uuid4
 
 from annotated_types import Len
-from orchestrator.forms import FormPage, SubmitFormPage
-from orchestrator.targets import Target
-from orchestrator.utils.json import json_dumps
-from orchestrator.workflow import StepList, begin, conditional, done, step, workflow
-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, Field
 from pydantic_forms.types import FormGenerator, State, UUIDstr
 from pydantic_forms.validators import Label, ReadOnlyField
@@ -30,6 +24,7 @@ from gso.products.product_blocks.iptrunk import (
 from gso.products.product_types.iptrunk import Iptrunk
 from gso.services.lso_client import LSOState, lso_interaction
 from gso.services.netbox_client import NetboxClient
+from gso.settings import ServiceConfig, load_gso_service_config
 from gso.utils.helpers import (
     available_interfaces_choices,
     available_interfaces_choices_including_current_members,
@@ -43,6 +38,12 @@ from gso.utils.types.tt_number import TTNumber
 from gso.utils.types.unique_field import validate_field_is_unique
 from gso.workflows.iptrunk.migrate_iptrunk import check_ip_trunk_optical_levels_pre
 from gso.workflows.iptrunk.validate_iptrunk import check_ip_trunk_isis
+from orchestrator.forms import FormPage, SubmitFormPage
+from orchestrator.targets import Target
+from orchestrator.utils.json import json_dumps
+from orchestrator.workflow import StepList, begin, conditional, done, step, workflow
+from orchestrator.workflows.steps import resync, store_process_subscription, unsync
+from orchestrator.workflows.utils import wrap_modify_initial_input_form
 
 
 def initialize_ae_members(
@@ -94,7 +95,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
             ]
             | None
         ) = subscription.iptrunk.gs_id
+
         iptrunk_description: str | None = subscription.iptrunk.iptrunk_description
+        iptrunk_config_version: load_gso_service_config().IP_TRUNK.version | str \
+            = subscription.iptrunk.iptrunk_config_version
         iptrunk_type: IptrunkType | str = subscription.iptrunk.iptrunk_type  # FIXME: remove str workaround
         warning_label: Label = (
             "Changing the PhyPortCapacity will result in the deletion of all AE members. "
@@ -268,6 +272,7 @@ def modify_iptrunk_subscription(
     iptrunk_speed: PhysicalPortCapacity,
     iptrunk_minimum_links: int,
     iptrunk_description_suffix: str | None,
+    iptrunk_config_version: ServiceConfig.version,
     side_a_ga_id: str | None,
     side_a_ae_members: list[dict],
     side_b_ga_id: str | None,
@@ -302,7 +307,7 @@ def modify_iptrunk_subscription(
     subscription.iptrunk.iptrunk_speed = iptrunk_speed
     subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links
     subscription.iptrunk.iptrunk_description_suffix = iptrunk_description_suffix
-
+    subscription.iptrunk.iptrunk_config_version = iptrunk_config_version
     subscription.iptrunk.iptrunk_sides[0].ga_id = side_a_ga_id
     update_side_members(subscription, 0, side_a_ae_members)
     subscription.iptrunk.iptrunk_sides[1].ga_id = side_b_ga_id
@@ -534,8 +539,8 @@ def modify_trunk_interface() -> StepList:
         >> capacity_has_changed(lso_interaction(check_ip_trunk_connectivity))
         >> capacity_has_changed(lso_interaction(check_ip_trunk_isis))
         >> modify_iptrunk_subscription
-        >> side_a_is_nokia(netbox_update_interfaces_side_a)
-        >> side_b_is_nokia(netbox_update_interfaces_side_b)
+        # >> side_a_is_nokia(netbox_update_interfaces_side_a)
+        # >> side_b_is_nokia(netbox_update_interfaces_side_b)
         >> lso_interaction(provision_ip_trunk_iface_dry)
         >> lso_interaction(provision_ip_trunk_iface_real)
         >> side_a_is_nokia(allocate_interfaces_in_netbox_side_a)
-- 
GitLab


From 4bc757f7534c96b4378d52877f7452dfc7539919 Mon Sep 17 00:00:00 2001
From: Saket Agrahari <saket.agrahari@geant.org>
Date: Fri, 16 May 2025 09:13:30 +0100
Subject: [PATCH 3/5] Update drop-down menus in IPtrunk workflows

changes for drop down menu

ruff fix

ruff

ruff

uncommenting steps

mypy issue

modify ip trunk test
---
 gso/gso-services-config.json                  |  6 ++--
 gso/products/product_blocks/iptrunk.py        |  4 +--
 gso/settings.py                               |  3 +-
 .../iptrunk/create_imported_iptrunk.py        | 15 ++++------
 gso/workflows/iptrunk/create_iptrunk.py       | 28 +++++++++--------
 .../iptrunk/modify_trunk_interface.py         | 30 ++++++++++---------
 .../iptrunk/test_create_imported_iptrunk.py   |  1 -
 test/workflows/iptrunk/test_create_iptrunk.py |  2 +-
 .../iptrunk/test_modify_trunk_interface.py    |  1 +
 9 files changed, 47 insertions(+), 43 deletions(-)

diff --git a/gso/gso-services-config.json b/gso/gso-services-config.json
index dc53bbff5..364ec44e6 100644
--- a/gso/gso-services-config.json
+++ b/gso/gso-services-config.json
@@ -1,13 +1,13 @@
 {
   "IP_TRUNK": {
-    "VERSION": {
+    "version": {
       "1.0": "Base Version",
-      "1.1":" Minor Upgrade"
+      "1.1": "Minor Upgrade"
     },
     "default_version": "1.0"
   },
   "GEANT_IP": {
-    "VERSION": {
+    "version": {
       "1.0": "Base Version",
       "2.0": "Major Upgrade"
     },
diff --git a/gso/products/product_blocks/iptrunk.py b/gso/products/product_blocks/iptrunk.py
index 1c6cb4a66..eaaab57fe 100644
--- a/gso/products/product_blocks/iptrunk.py
+++ b/gso/products/product_blocks/iptrunk.py
@@ -4,6 +4,8 @@ import ipaddress
 from typing import Annotated
 
 from annotated_types import Len
+from orchestrator.domain.base import ProductBlockModel, T
+from orchestrator.types import SubscriptionLifecycle
 from pydantic import AfterValidator
 from pydantic_forms.types import strEnum
 from pydantic_forms.validators import validate_unique_list
@@ -15,8 +17,6 @@ from gso.products.product_blocks.router import (
     RouterBlockProvisioning,
 )
 from gso.utils.types.interfaces import LAGMemberList, PhysicalPortCapacity
-from orchestrator.domain.base import ProductBlockModel, T
-from orchestrator.types import SubscriptionLifecycle
 
 
 class IptrunkType(strEnum):
diff --git a/gso/settings.py b/gso/settings.py
index 2059a6023..65af78ac8 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -253,12 +253,13 @@ def load_oss_params() -> OSSParams:
 class ServiceConfig(BaseSettings):
     """Configurations for base gso service."""
 
-    version:  dict[str, str]
+    version: dict[str, str]
     default_version: str
 
 
 class GSOServiceConfig(BaseSettings):
     """Configuration for the GSO service."""
+
     IP_TRUNK: ServiceConfig
     GEANT_IP: ServiceConfig
 
diff --git a/gso/workflows/iptrunk/create_imported_iptrunk.py b/gso/workflows/iptrunk/create_imported_iptrunk.py
index d65dee753..4c45751b9 100644
--- a/gso/workflows/iptrunk/create_imported_iptrunk.py
+++ b/gso/workflows/iptrunk/create_imported_iptrunk.py
@@ -4,6 +4,12 @@ import ipaddress
 from typing import Annotated
 from uuid import uuid4
 
+from orchestrator import workflow
+from orchestrator.forms import SubmitFormPage
+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
 from pydantic import AfterValidator, ConfigDict
 from pydantic_forms.types import FormGenerator, State
 from pydantic_forms.validators import validate_unique_list
@@ -17,12 +23,6 @@ from gso.services.partners import get_partner_by_name
 from gso.utils.helpers import active_router_selector
 from gso.utils.types.geant_ids import IMPORTED_GA_ID, IMPORTED_GS_ID
 from gso.utils.types.interfaces import LAGMember, LAGMemberList, PhysicalPortCapacity
-from orchestrator import workflow
-from orchestrator.forms import SubmitFormPage
-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
 
 
 def initial_input_form_generator() -> FormGenerator:
@@ -39,7 +39,6 @@ def initial_input_form_generator() -> FormGenerator:
         iptrunk_minimum_links: int
         iptrunk_isis_metric: int
         iptrunk_description_suffix: str | None = None
-        iptrunk_config_version: str | None = None
 
         side_a_node_id: active_router_selector()  # type: ignore[valid-type]
         side_a_ae_iface: str
@@ -90,7 +89,6 @@ def initialize_subscription(
     side_b_ae_iface: str,
     side_b_ga_id: IMPORTED_GA_ID | None,
     side_b_ae_members: LAGMemberList,
-    iptrunk_config_version: str | None,
 ) -> State:
     """Take all input from the user, and store it in the database."""
     subscription.iptrunk.gs_id = gs_id
@@ -100,7 +98,6 @@ def initialize_subscription(
     subscription.iptrunk.iptrunk_isis_metric = iptrunk_isis_metric
     subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links
     subscription.iptrunk.iptrunk_description_suffix = iptrunk_description_suffix
-    subscription.iptrunk.iptrunk_config_version = iptrunk_config_version
 
     subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node = Router.from_subscription(side_a_node_id).router
     subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface = side_a_ae_iface
diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py
index 72f6a6c9a..cb9f83231 100644
--- a/gso/workflows/iptrunk/create_iptrunk.py
+++ b/gso/workflows/iptrunk/create_iptrunk.py
@@ -31,6 +31,15 @@ from typing import Annotated
 from uuid import uuid4
 
 from annotated_types import Len
+from orchestrator.forms import FormPage
+from orchestrator.forms.validators import Choice, Label
+from orchestrator.targets import Target
+from orchestrator.types import SubscriptionLifecycle
+from orchestrator.utils.errors import ProcessFailureError
+from orchestrator.utils.json import json_dumps
+from orchestrator.workflow import StepList, begin, conditional, done, step, step_group, workflow
+from orchestrator.workflows.steps import resync, set_status, store_process_subscription
+from orchestrator.workflows.utils import wrap_create_initial_input_form
 from ping3 import ping
 from pydantic import ConfigDict
 from pydantic_forms.types import FormGenerator, State, UUIDstr
@@ -53,7 +62,7 @@ from gso.services.subscriptions import (
     generate_unique_id,
     get_non_terminated_iptrunk_subscriptions,
 )
-from gso.settings import load_oss_params
+from gso.settings import load_gso_service_config, load_oss_params
 from gso.utils.helpers import (
     available_interfaces_choices,
     available_lags_choices,
@@ -66,15 +75,6 @@ from gso.utils.types.netbox_router import NetboxEnabledRouter
 from gso.utils.types.tt_number import TTNumber
 from gso.utils.workflow_steps import prompt_sharepoint_checklist_url
 from gso.workflows.shared import create_summary_form
-from orchestrator.forms import FormPage
-from orchestrator.forms.validators import Choice, Label
-from orchestrator.targets import Target
-from orchestrator.types import SubscriptionLifecycle
-from orchestrator.utils.errors import ProcessFailureError
-from orchestrator.utils.json import json_dumps
-from orchestrator.workflow import StepList, begin, conditional, done, step, step_group, workflow
-from orchestrator.workflows.steps import resync, set_status, store_process_subscription
-from orchestrator.workflows.utils import wrap_create_initial_input_form
 
 
 def initial_input_form_generator(product_name: str) -> FormGenerator:
@@ -85,6 +85,10 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
     ) + subscriptions.get_provisioning_router_subscriptions(includes=["subscription_id", "description"])
     routers = {str(router["subscription_id"]): router["description"] for router in active_and_provisioning_routers}
 
+    # Get version choices from config
+    iptrunk_versions = list(load_gso_service_config().IP_TRUNK.version.keys())
+    iptrunk_version_choices = Choice("Select version", [(v, v) for v in iptrunk_versions])  # type: ignore[arg-type]
+
     class CreateIptrunkForm(FormPage):
         model_config = ConfigDict(title=product_name)
 
@@ -95,7 +99,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
         iptrunk_speed: PhysicalPortCapacity
         iptrunk_number_of_members: int
         iptrunk_description_suffix: str | None = None
-        iptrunk_config_version: str | None = None
+        iptrunk_config_version: iptrunk_version_choices  # type: ignore[valid-type]
 
     initial_user_input = yield CreateIptrunkForm
     recommended_minimum_links = calculate_recommended_minimum_links(
@@ -329,7 +333,7 @@ def initialize_subscription(
     iptrunk_speed: PhysicalPortCapacity,
     iptrunk_minimum_links: int,
     iptrunk_description_suffix: str | None,
-    iptrunk_config_version: str | None,
+    iptrunk_config_version: str,
     side_a_node_id: str,
     side_a_ae_iface: str,
     side_a_ae_members: list[dict],
diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py
index 793c1bc00..391832078 100644
--- a/gso/workflows/iptrunk/modify_trunk_interface.py
+++ b/gso/workflows/iptrunk/modify_trunk_interface.py
@@ -12,9 +12,15 @@ from typing import Annotated
 from uuid import UUID, uuid4
 
 from annotated_types import Len
+from orchestrator.forms import FormPage, SubmitFormPage
+from orchestrator.targets import Target
+from orchestrator.utils.json import json_dumps
+from orchestrator.workflow import StepList, begin, conditional, done, step, workflow
+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, Field
 from pydantic_forms.types import FormGenerator, State, UUIDstr
-from pydantic_forms.validators import Label, ReadOnlyField
+from pydantic_forms.validators import Choice, Label, ReadOnlyField
 
 from gso.products.product_blocks.iptrunk import (
     IptrunkInterfaceBlock,
@@ -24,7 +30,7 @@ from gso.products.product_blocks.iptrunk import (
 from gso.products.product_types.iptrunk import Iptrunk
 from gso.services.lso_client import LSOState, lso_interaction
 from gso.services.netbox_client import NetboxClient
-from gso.settings import ServiceConfig, load_gso_service_config
+from gso.settings import load_gso_service_config
 from gso.utils.helpers import (
     available_interfaces_choices,
     available_interfaces_choices_including_current_members,
@@ -38,12 +44,6 @@ from gso.utils.types.tt_number import TTNumber
 from gso.utils.types.unique_field import validate_field_is_unique
 from gso.workflows.iptrunk.migrate_iptrunk import check_ip_trunk_optical_levels_pre
 from gso.workflows.iptrunk.validate_iptrunk import check_ip_trunk_isis
-from orchestrator.forms import FormPage, SubmitFormPage
-from orchestrator.targets import Target
-from orchestrator.utils.json import json_dumps
-from orchestrator.workflow import StepList, begin, conditional, done, step, workflow
-from orchestrator.workflows.steps import resync, store_process_subscription, unsync
-from orchestrator.workflows.utils import wrap_modify_initial_input_form
 
 
 def initialize_ae_members(
@@ -87,6 +87,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     """Gather input from the operator on the interfaces that should be modified."""
     subscription = Iptrunk.from_subscription(subscription_id)
 
+    # Get version choices from config (single-select dropdown)
+    iptrunk_versions = list(load_gso_service_config().IP_TRUNK.version.keys())
+    iptrunk_version_choices = Choice("Select version", [(v, v) for v in iptrunk_versions])  # type: ignore[arg-type]
+
     class ModifyIptrunkForm(FormPage):
         tt_number: TTNumber
         gs_id: (
@@ -95,10 +99,8 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
             ]
             | None
         ) = subscription.iptrunk.gs_id
-
         iptrunk_description: str | None = subscription.iptrunk.iptrunk_description
-        iptrunk_config_version: load_gso_service_config().IP_TRUNK.version | str \
-            = subscription.iptrunk.iptrunk_config_version
+        iptrunk_config_version: iptrunk_version_choices = subscription.iptrunk.iptrunk_config_version  # type: ignore[valid-type]
         iptrunk_type: IptrunkType | str = subscription.iptrunk.iptrunk_type  # FIXME: remove str workaround
         warning_label: Label = (
             "Changing the PhyPortCapacity will result in the deletion of all AE members. "
@@ -272,7 +274,7 @@ def modify_iptrunk_subscription(
     iptrunk_speed: PhysicalPortCapacity,
     iptrunk_minimum_links: int,
     iptrunk_description_suffix: str | None,
-    iptrunk_config_version: ServiceConfig.version,
+    iptrunk_config_version: str,
     side_a_ga_id: str | None,
     side_a_ae_members: list[dict],
     side_b_ga_id: str | None,
@@ -539,8 +541,8 @@ def modify_trunk_interface() -> StepList:
         >> capacity_has_changed(lso_interaction(check_ip_trunk_connectivity))
         >> capacity_has_changed(lso_interaction(check_ip_trunk_isis))
         >> modify_iptrunk_subscription
-        # >> side_a_is_nokia(netbox_update_interfaces_side_a)
-        # >> side_b_is_nokia(netbox_update_interfaces_side_b)
+        >> side_a_is_nokia(netbox_update_interfaces_side_a)
+        >> side_b_is_nokia(netbox_update_interfaces_side_b)
         >> lso_interaction(provision_ip_trunk_iface_dry)
         >> lso_interaction(provision_ip_trunk_iface_real)
         >> side_a_is_nokia(allocate_interfaces_in_netbox_side_a)
diff --git a/test/workflows/iptrunk/test_create_imported_iptrunk.py b/test/workflows/iptrunk/test_create_imported_iptrunk.py
index 7add53de9..7f83775bb 100644
--- a/test/workflows/iptrunk/test_create_imported_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_imported_iptrunk.py
@@ -23,7 +23,6 @@ def workflow_input_data(faker, router_subscription_factory):
         "iptrunk_minimum_links": 2,
         "iptrunk_isis_metric": 10000,
         "iptrunk_description_suffix": faker.word(),
-        "iptrunk_config_version": "1.0",
         "side_a_node_id": str(router_subscription_factory().subscription_id),
         "side_a_ae_iface": faker.nokia_lag_interface_name(),
         "side_a_ga_id": faker.imported_ga_id(),
diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py
index c18577249..33c9640aa 100644
--- a/test/workflows/iptrunk/test_create_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_iptrunk.py
@@ -74,7 +74,7 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
         "iptrunk_speed": PhysicalPortCapacity.HUNDRED_GIGABIT_PER_SECOND,
         "iptrunk_number_of_members": 2,
         "iptrunk_description_suffix": faker.word(),
-        "iptrunk_config_version": "1.0"
+        "iptrunk_config_version": "1.0",
     }
     create_ip_trunk_confirm_step = {"iptrunk_minimum_links": 1}
     create_ip_trunk_side_a_router_name = {"side_a_node_id": router_side_a}
diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py
index 4e72fbd72..7c11c9551 100644
--- a/test/workflows/iptrunk/test_modify_trunk_interface.py
+++ b/test/workflows/iptrunk/test_modify_trunk_interface.py
@@ -121,6 +121,7 @@ def input_form_iptrunk_data(
             "iptrunk_speed": new_speed,
             "iptrunk_number_of_members": new_link_count,
             "iptrunk_description_suffix": faker.word(),
+            "iptrunk_config_version": "1.0",
         },
         {},
         {
-- 
GitLab


From fa7aec1936e0459567526cea43ae377b18b9839a Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Mon, 26 May 2025 11:35:40 +0200
Subject: [PATCH 4/5] Move GSO service version config into oss-params

---
 gso/gso-services-config.json                  | 16 --------
 gso/oss-params-example.json                   | 16 ++++++++
 gso/settings.py                               | 37 ++++++++-----------
 gso/workflows/iptrunk/create_iptrunk.py       |  4 +-
 .../iptrunk/modify_trunk_interface.py         |  4 +-
 5 files changed, 35 insertions(+), 42 deletions(-)
 delete mode 100644 gso/gso-services-config.json

diff --git a/gso/gso-services-config.json b/gso/gso-services-config.json
deleted file mode 100644
index 364ec44e6..000000000
--- a/gso/gso-services-config.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "IP_TRUNK": {
-    "version": {
-      "1.0": "Base Version",
-      "1.1": "Minor Upgrade"
-    },
-    "default_version": "1.0"
-  },
-  "GEANT_IP": {
-    "version": {
-      "1.0": "Base Version",
-      "2.0": "Major Upgrade"
-    },
-    "default_version": "1.0"
-  }
-}
diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json
index ea85bf24e..2b60d84c8 100644
--- a/gso/oss-params-example.json
+++ b/gso/oss-params-example.json
@@ -130,5 +130,21 @@
   "MOODI": {
     "host": "moodi.test.gap.geant.org",
     "moodi_enabled": true
+  },
+  "SERVICE_VERSIONS": {
+    "IP_TRUNK": {
+      "version": {
+        "1.0": "Base Version",
+        "1.1": "Minor Upgrade"
+      },
+      "default_version": "1.0"
+    },
+    "GEANT_IP": {
+      "version": {
+        "1.0": "Base Version",
+        "2.0": "Major Upgrade"
+      },
+      "default_version": "1.0"
+    }
   }
 }
diff --git a/gso/settings.py b/gso/settings.py
index 65af78ac8..8c1b0ec8e 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -228,6 +228,20 @@ class MoodiParams(BaseSettings):
     moodi_enabled: bool = False
 
 
+class ServiceConfig(BaseSettings):
+    """Base configuration object for setting version information of a service."""
+
+    version: dict[str, str]
+    default_version: str
+
+
+class ServiceVersionConfig(BaseSettings):
+    """Services offered by GSO that support multiple versions."""
+
+    IP_TRUNK: ServiceConfig
+    GEANT_IP: ServiceConfig
+
+
 class OSSParams(BaseSettings):
     """The set of parameters required for running GSO."""
 
@@ -242,6 +256,7 @@ class OSSParams(BaseSettings):
     KENTIK: KentikParams
     SENTRY: SentryParams | None = None
     MOODI: MoodiParams
+    SERVICE_VERSIONS: ServiceVersionConfig
 
 
 def load_oss_params() -> OSSParams:
@@ -250,29 +265,7 @@ def load_oss_params() -> OSSParams:
         return OSSParams(**json.loads(file.read()))
 
 
-class ServiceConfig(BaseSettings):
-    """Configurations for base gso service."""
-
-    version: dict[str, str]
-    default_version: str
-
-
-class GSOServiceConfig(BaseSettings):
-    """Configuration for the GSO service."""
-
-    IP_TRUNK: ServiceConfig
-    GEANT_IP: ServiceConfig
-
-
-def load_gso_service_config() -> GSOServiceConfig:
-    """Load the GSO service configuration from the environment variable."""
-    """Look for ``GSO_SERVICE_CONFIG`` in the environment and load the parameters from that file."""
-    with Path(os.environ["GSO_SERVICE_CONFIG"]).open(encoding="utf-8") as file:
-        return GSOServiceConfig(**json.loads(file.read()))
-
-
 celery_settings = CelerySettings()
 
 if __name__ == "__main__":
     logger.debug(load_oss_params())
-    logger.debug(load_gso_service_config())
diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py
index cb9f83231..04b0d1459 100644
--- a/gso/workflows/iptrunk/create_iptrunk.py
+++ b/gso/workflows/iptrunk/create_iptrunk.py
@@ -62,7 +62,7 @@ from gso.services.subscriptions import (
     generate_unique_id,
     get_non_terminated_iptrunk_subscriptions,
 )
-from gso.settings import load_gso_service_config, load_oss_params
+from gso.settings import load_oss_params
 from gso.utils.helpers import (
     available_interfaces_choices,
     available_lags_choices,
@@ -86,7 +86,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
     routers = {str(router["subscription_id"]): router["description"] for router in active_and_provisioning_routers}
 
     # Get version choices from config
-    iptrunk_versions = list(load_gso_service_config().IP_TRUNK.version.keys())
+    iptrunk_versions = list(load_oss_params().SERVICE_VERSIONS.IP_TRUNK.version.keys())
     iptrunk_version_choices = Choice("Select version", [(v, v) for v in iptrunk_versions])  # type: ignore[arg-type]
 
     class CreateIptrunkForm(FormPage):
diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py
index 391832078..1f3e9f543 100644
--- a/gso/workflows/iptrunk/modify_trunk_interface.py
+++ b/gso/workflows/iptrunk/modify_trunk_interface.py
@@ -30,7 +30,7 @@ from gso.products.product_blocks.iptrunk import (
 from gso.products.product_types.iptrunk import Iptrunk
 from gso.services.lso_client import LSOState, lso_interaction
 from gso.services.netbox_client import NetboxClient
-from gso.settings import load_gso_service_config
+from gso.settings import load_oss_params
 from gso.utils.helpers import (
     available_interfaces_choices,
     available_interfaces_choices_including_current_members,
@@ -88,7 +88,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     subscription = Iptrunk.from_subscription(subscription_id)
 
     # Get version choices from config (single-select dropdown)
-    iptrunk_versions = list(load_gso_service_config().IP_TRUNK.version.keys())
+    iptrunk_versions = list(load_oss_params().SERVICE_VERSIONS.IP_TRUNK.version.keys())
     iptrunk_version_choices = Choice("Select version", [(v, v) for v in iptrunk_versions])  # type: ignore[arg-type]
 
     class ModifyIptrunkForm(FormPage):
-- 
GitLab


From ea5a7091b19fcd3c4d25e7889ed12843e90c1843 Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Mon, 26 May 2025 15:25:12 +0200
Subject: [PATCH 5/5] Abstract IP trunk version selector into utils

---
 gso/utils/helpers.py                          | 21 +++++++++++++++----
 gso/workflows/iptrunk/create_iptrunk.py       |  7 ++-----
 .../iptrunk/modify_trunk_interface.py         | 10 +++------
 test/workflows/iptrunk/test_create_iptrunk.py |  2 +-
 4 files changed, 23 insertions(+), 17 deletions(-)

diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index c20e8a4e7..9a73d9c78 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -10,7 +10,6 @@ from orchestrator.types import SubscriptionLifecycle
 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
@@ -25,6 +24,7 @@ from gso.services.subscriptions import (
     get_router_subscriptions,
     is_virtual_circuit_id_available,
 )
+from gso.settings import load_oss_params
 from gso.utils.shared_enums import Vendor
 from gso.utils.types.interfaces import PhysicalPortCapacity
 from gso.utils.types.ip_address import IPv4AddressType, IPv4NetworkType, IPv6NetworkType
@@ -131,13 +131,13 @@ def iso_from_ipv4(ipv4_address: IPv4AddressType) -> str:
 
 def generate_fqdn(hostname: str, site_name: str, country_code: str) -> str:
     """Generate an FQDN from a hostname, site name, and a country code."""
-    oss = settings.load_oss_params()
+    oss = load_oss_params()
     return f"{hostname}.{site_name.lower()}.{country_code.lower()}{oss.IPAM.LO.domain_name}"
 
 
 def generate_lan_switch_interconnect_subnet_v4(site_internal_id: int) -> IPv4NetworkType:
     """Generate an IPv4 network in which a LAN Switch Interconnect resides, given a Site internal ID."""
-    ipam_oss = settings.load_oss_params().IPAM.LAN_SWITCH_INTERCONNECT
+    ipam_oss = load_oss_params().IPAM.LAN_SWITCH_INTERCONNECT
 
     result = str(ipam_oss.V4.containers[0]).split(".")[:2]  # Take the first two octets from the IPv4 network.
     result.append(str(site_internal_id))  # Append the side ID as the third octet.
@@ -148,7 +148,7 @@ def generate_lan_switch_interconnect_subnet_v4(site_internal_id: int) -> IPv4Net
 
 def generate_lan_switch_interconnect_subnet_v6(site_internal_id: int) -> IPv6NetworkType:
     """Generate an IPv6 network in which a LAN Switch Interconnect resides, given a Site internal ID."""
-    ipam_oss = settings.load_oss_params().IPAM.LAN_SWITCH_INTERCONNECT
+    ipam_oss = load_oss_params().IPAM.LAN_SWITCH_INTERCONNECT
 
     result = IPv6Network(ipam_oss.V6.containers[0]).exploded[:17]  # Take the first 56 bits of the network
     result += str(hex(site_internal_id)[2:])  # Append the site internal id for bytes 57 to 64 as hexadecimal number
@@ -283,6 +283,19 @@ def active_edge_port_selector(*, partner_id: UUIDstr | None = None) -> TypeAlias
     )
 
 
+def ip_trunk_service_version_selector() -> TypeAlias:
+    """Generate a dropdown selector for choosing a service version."""
+    iptrunk_versions = load_oss_params().SERVICE_VERSIONS.IP_TRUNK.version
+
+    return cast(
+        type[Choice],
+        Choice.__call__(
+            "Select an IP trunk service version.",
+            [(k, f"Version {k} - {iptrunk_versions[k]}") for k in iptrunk_versions],
+        ),
+    )
+
+
 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()}
diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py
index 04b0d1459..e151068a2 100644
--- a/gso/workflows/iptrunk/create_iptrunk.py
+++ b/gso/workflows/iptrunk/create_iptrunk.py
@@ -68,6 +68,7 @@ from gso.utils.helpers import (
     available_lags_choices,
     calculate_recommended_minimum_links,
     get_router_vendor,
+    ip_trunk_service_version_selector,
 )
 from gso.utils.shared_enums import Vendor
 from gso.utils.types.interfaces import JuniperLAGMember, LAGMember, LAGMemberList, PhysicalPortCapacity
@@ -85,10 +86,6 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
     ) + subscriptions.get_provisioning_router_subscriptions(includes=["subscription_id", "description"])
     routers = {str(router["subscription_id"]): router["description"] for router in active_and_provisioning_routers}
 
-    # Get version choices from config
-    iptrunk_versions = list(load_oss_params().SERVICE_VERSIONS.IP_TRUNK.version.keys())
-    iptrunk_version_choices = Choice("Select version", [(v, v) for v in iptrunk_versions])  # type: ignore[arg-type]
-
     class CreateIptrunkForm(FormPage):
         model_config = ConfigDict(title=product_name)
 
@@ -99,7 +96,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
         iptrunk_speed: PhysicalPortCapacity
         iptrunk_number_of_members: int
         iptrunk_description_suffix: str | None = None
-        iptrunk_config_version: iptrunk_version_choices  # type: ignore[valid-type]
+        iptrunk_config_version: ip_trunk_service_version_selector()  # type: ignore[valid-type]
 
     initial_user_input = yield CreateIptrunkForm
     recommended_minimum_links = calculate_recommended_minimum_links(
diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py
index 1f3e9f543..9bb651f5e 100644
--- a/gso/workflows/iptrunk/modify_trunk_interface.py
+++ b/gso/workflows/iptrunk/modify_trunk_interface.py
@@ -20,7 +20,7 @@ from orchestrator.workflows.steps import resync, store_process_subscription, uns
 from orchestrator.workflows.utils import wrap_modify_initial_input_form
 from pydantic import AfterValidator, ConfigDict, Field
 from pydantic_forms.types import FormGenerator, State, UUIDstr
-from pydantic_forms.validators import Choice, Label, ReadOnlyField
+from pydantic_forms.validators import Label, ReadOnlyField
 
 from gso.products.product_blocks.iptrunk import (
     IptrunkInterfaceBlock,
@@ -30,12 +30,12 @@ from gso.products.product_blocks.iptrunk import (
 from gso.products.product_types.iptrunk import Iptrunk
 from gso.services.lso_client import LSOState, lso_interaction
 from gso.services.netbox_client import NetboxClient
-from gso.settings import load_oss_params
 from gso.utils.helpers import (
     available_interfaces_choices,
     available_interfaces_choices_including_current_members,
     calculate_recommended_minimum_links,
     get_router_vendor,
+    ip_trunk_service_version_selector,
 )
 from gso.utils.shared_enums import Vendor
 from gso.utils.types.interfaces import JuniperLAGMember, LAGMember, LAGMemberList, PhysicalPortCapacity
@@ -87,10 +87,6 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
     """Gather input from the operator on the interfaces that should be modified."""
     subscription = Iptrunk.from_subscription(subscription_id)
 
-    # Get version choices from config (single-select dropdown)
-    iptrunk_versions = list(load_oss_params().SERVICE_VERSIONS.IP_TRUNK.version.keys())
-    iptrunk_version_choices = Choice("Select version", [(v, v) for v in iptrunk_versions])  # type: ignore[arg-type]
-
     class ModifyIptrunkForm(FormPage):
         tt_number: TTNumber
         gs_id: (
@@ -100,7 +96,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
             | None
         ) = subscription.iptrunk.gs_id
         iptrunk_description: str | None = subscription.iptrunk.iptrunk_description
-        iptrunk_config_version: iptrunk_version_choices = subscription.iptrunk.iptrunk_config_version  # type: ignore[valid-type]
+        iptrunk_config_version: ip_trunk_service_version_selector() | str = subscription.iptrunk.iptrunk_config_version  # type: ignore[valid-type]
         iptrunk_type: IptrunkType | str = subscription.iptrunk.iptrunk_type  # FIXME: remove str workaround
         warning_label: Label = (
             "Changing the PhyPortCapacity will result in the deletion of all AE members. "
diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py
index 33c9640aa..851c3e308 100644
--- a/test/workflows/iptrunk/test_create_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_iptrunk.py
@@ -74,7 +74,7 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
         "iptrunk_speed": PhysicalPortCapacity.HUNDRED_GIGABIT_PER_SECOND,
         "iptrunk_number_of_members": 2,
         "iptrunk_description_suffix": faker.word(),
-        "iptrunk_config_version": "1.0",
+        "iptrunk_config_version": "Version 1.0 - Base Version",
     }
     create_ip_trunk_confirm_step = {"iptrunk_minimum_links": 1}
     create_ip_trunk_side_a_router_name = {"side_a_node_id": router_side_a}
-- 
GitLab