diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..6deafc2
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,2 @@
+[flake8]
+max-line-length = 120
diff --git a/README.md b/README.md
index f5f0743..6b92137 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,8 @@ Example workflow orchestrator implementation based on the
- [Example orchestrator](#example-orchestrator)
- [Folder layout](#folder-layout)
- [migrations/versions/schema](#migrationsversionsschema)
- - [products/product\_types](#productsproduct_types)
- - [products/product\_blocks](#productsproduct_blocks)
+ - [products/product_types](#productsproduct_types)
+ - [products/product_blocks](#productsproduct_blocks)
- [products/services](#productsservices)
- [services](#services)
- [templates](#templates)
@@ -95,14 +95,12 @@ To access the new v2 `orchestrator-ui`, point your browser to:
http://localhost:3000/
```
-
To access `netbox` (admin/admin), point your browser to:
```
http://localhost:8000/
```
-
To access `federation`, point your browser to:
```
@@ -114,43 +112,43 @@ http://localhost:4000
Use the following steps to see the example orchestrator in action:
1. Bootstrap NetBox
- 1. from the `Tasks` page click `New Task`
- 2. select `NetBox Bootstrap` and click `Start task`
- 3. select `Expand all` on the following page to see the step details
+ 1. from the `Tasks` page click `New Task`
+ 2. select `NetBox Bootstrap` and click `Start task`
+ 3. select `Expand all` on the following page to see the step details
2. Create a network node (need at least two to create a core link)
- 1. in the left-above corner, click on `New subscription`
- 2. select either the `Node Cisco` or `Node Nokia`
- 3. fill in the needed fields, click `Start workflow` and view the summary form
- 4. click `Start workflow` again to start the workflow, or click `Previous` to modify fields
+ 1. in the left-above corner, click on `New subscription`
+ 2. select either the `Node Cisco` or `Node Nokia`
+ 3. fill in the needed fields, click `Start workflow` and view the summary form
+ 4. click `Start workflow` again to start the workflow, or click `Previous` to modify fields
3. Add interfaces to a node (needed by the other products)
- 1. on the `Subscriptions` page, click on the subscription description of the node to show the details
- 2. select `Update node interfaces` from the `Actions` pulldown
+ 1. on the `Subscriptions` page, click on the subscription description of the node to show the details
+ 2. select `Update node interfaces` from the `Actions` pulldown
4. Create a core link
- 1. in the left-above corner, click on `New subscription`
- 2. select either the `core link 10G` or `core link 100G`
- 3. fill in the forms and finally click on `Start workflow` to start the workflow
+ 1. in the left-above corner, click on `New subscription`
+ 2. select either the `core link 10G` or `core link 100G`
+ 3. fill in the forms and finally click on `Start workflow` to start the workflow
5. Create a customer port (need at least two **tagged** ports to create a l2vpn)
- 1. use `New subscription` for either a `port 10G` or a `port 100G`
- 3. fill in the forms and click on `Start workflow` to start the workflow
+ 1. use `New subscription` for either a `port 10G` or a `port 100G`
+ 2. fill in the forms and click on `Start workflow` to start the workflow
6. Create a l2vpn
- 1. use `New subscription` for a `l2vpn`, fill in the forms, and `Start workflow`
+ 1. use `New subscription` for a `l2vpn`, fill in the forms, and `Start workflow`
While running the different workflows, have a look at the following
netbox pages to see the orchestrator interact with netbox:
- Devices
- - Devices
- - Interfaces
+ - Devices
+ - Interfaces
- Connections
- - Cables
- - Interface Connections
+ - Cables
+ - Interface Connections
- IPAM
- - IP Addresses
- - Prefixes
- - VLANs
+ - IP Addresses
+ - Prefixes
+ - VLANs
- Overlay
- - L2VPNs
- - Terminations
+ - L2VPNs
+ - Terminations
## Summary
@@ -212,9 +210,9 @@ based on a simple fictional NREN that has the following characteristics:
- The network nodes are connected to each other through core links
- On top of this substrate a set of services like Internet Access, L3VPN and L2VPN are offered
- The Operations Support Systems (OSS) used are:
- - An IP Administration Management (IPAM) tool
- - A network Inventory Management System (IMS)
- - A Network Resource Manager (NRM) to provision the network
+ - An IP Administration Management (IPAM) tool
+ - A network Inventory Management System (IMS)
+ - A Network Resource Manager (NRM) to provision the network
- There is no Business Support System (BSS) yet
This NREN decided on a phased introduction of automation in their
@@ -222,15 +220,15 @@ organisation, only automating some of the procedures and flows of
information while leaving others unautomated for the moment:
- Automated administration and provisioning of:
- - Network nodes including loopback IP addresses
- - Core links in between network nodes including point-to-point IP addresses
- - Customer ports
- - Customer L2VPN’s
+ - Network nodes including loopback IP addresses
+ - Core links in between network nodes including point-to-point IP addresses
+ - Customer ports
+ - Customer L2VPN’s
- Not automated administration and provisioning of:
- - Role, make and model of the network nodes
- - Sites where network nodes are installed
- - Customer services like Internet Access, L3VPN, …
- - Internet peering
+ - Role, make and model of the network nodes
+ - Sites where network nodes are installed
+ - Customer services like Internet Access, L3VPN, …
+ - Internet peering
NetBox[^3] is used as IMS and IPAM, and serves as the source of truth
for the complete IP address administration and physical and logical
@@ -417,6 +415,7 @@ When this example orchestrator is deployed, it can create a growing
graph of product blocks as is shown below.
### Product Hiearchy Diagram
+
### How to use
@@ -487,6 +486,10 @@ should exist, and every port can only be used once in the same L2VPN.
This product is only supported on tagged interfaces, and VLAN retagging
is not supported.
+#### NSISTP
+
+The Network Service Interface (NSISTP) / Service Termination Point (STP) represents the logical endpoint where a network service connects to a customer port. To create an NSISTP, at least one port subscription must exist. The NSISTP workflow allows you to define service-specific parameters such as VLAN assignment and Service Speed. NSISTP can only be created on tagged ports, as untagged ports are limited to a single service.
+
## Products
The Orchestrator uses the concept of a Product to describe what can be built to the end user. When
@@ -496,6 +499,7 @@ is unique per customer. In other words a Subscription contains all the informati
resource owned by a user/customer that conforms to a certain definition, namely a Product.
### Product description in Python
+
Products are described in Python classes called Domain Models. These classes are designed to help the
developer manage complex subscription models and interact with the
objects in a developer-friendly way. Domain models use Pydantic[^6] with some
@@ -507,6 +511,7 @@ type checkers, already helps to make the code more robust, furthermore the use o
variables at runtime which greatly improves reliability.
#### Example of "Runtime typecasting/safety"
+
In the example below we attempt to access a resource that has been stored in an instance of a product
(subscription instance). It shows how it can be done directly through the ORM and it shows the added value of Domain
Models on top of the ORM.
@@ -526,6 +531,7 @@ Models on top of the ORM.
```
**Serialisation using domain models**
+
```python
>>> class ProductBlock(ProductBlockModel):
... instance_from_db: bool
@@ -548,10 +554,12 @@ False
... print("False")
"False"
```
+
As you can see in the example above, interacting with the data stored in the database rows, helps with some of the heavy
lifting, and makes sure the database remains generic and it's schema remains stable.
#### Product Structure
+
A Product definition has two parts in its structure. The Higher order product type that contains information describing
the product in a more general sense, and multiple layers of product blocks that logically describe the set of resources
that make up the product definition. The product type describes the fixed inputs and the top-level product blocks.
@@ -563,13 +571,14 @@ product blocks as well. If a fixed input needs a custom type, then it is
defined here together with fixed input definition.
#### Terminology
- * **Product:** A definition of what can be instantiated through a Subscription.
- * **Product Type:** The higher order definition of a Product. Many different Products can exist within a Product Type.
- * **Fixed Input:** Product attributes that discriminate the different Products that adhere to the same Product Type definition.
- * **Product Block:** A (logical) construct that contain references to other Product Blocks or Resource Types. It gives
- structure to the product definition and defines what resources are related to other resources
- * **Resource Types:** Customer facing attributes that are the result of choices made by the user whilst filling an
- input form. This can be a value the user chose, or an identifier towards a different system.
+
+- **Product:** A definition of what can be instantiated through a Subscription.
+- **Product Type:** The higher order definition of a Product. Many different Products can exist within a Product Type.
+- **Fixed Input:** Product attributes that discriminate the different Products that adhere to the same Product Type definition.
+- **Product Block:** A (logical) construct that contain references to other Product Blocks or Resource Types. It gives
+ structure to the product definition and defines what resources are related to other resources
+- **Resource Types:** Customer facing attributes that are the result of choices made by the user whilst filling an
+ input form. This can be a value the user chose, or an identifier towards a different system.
### Product types
@@ -584,6 +593,7 @@ before it ends up terminated. The terminated state does not have its own
type definition, but will default to initial unless otherwise defined.
#### Domain Model a.k.a Product Type Definition
+
```python
class PortInactive(SubscriptionModel, is_base=True):
speed: PortSpeed
@@ -608,6 +618,7 @@ product block, but it is totally fine to use product blocks from
different lifecycle states if that suits your use case.
#### Fixed Input
+
Because a port is only available in a limited number of speeds, a
separate type is declared with the allowed values, see below.
@@ -628,6 +639,7 @@ choices, and in the database migration to register the speed variant of
this product.
#### Wiring it up in the Orchestrator
+
This section contains advanced information about how to configure the Orchestrator. It is also possible to use
a more user friendly tool available State:
@@ -839,10 +857,12 @@ def my_ugly_step(state: State) -> State:
state["subscription"] = subscription
return state
```
+
In the above example you see we do a simple calculation based on `variable_1`. When computing with even more
variables, you van imagine how unreadable the function will be. Now consider the next example.
**Good use of the step decorator**
+
```python
@step("Good use of the input params functionality")
def my_beautiful_step(variable_1: int, variable_2: str, subscription: SubscriptionModel) -> State:
@@ -859,11 +879,11 @@ def my_beautiful_step(variable_1: int, variable_2: str, subscription: Subscripti
As you can see the Orchestrator the orchestrator helps you a lot to condense the logic in your function. The `@step`
decorator does the following:
-* Loads the previous steps state from the database.
-* Inspects the step functions signature
-* Finds the arguments in the state and injects them as function arguments to the step function
-* It casts them to the correct type by using the type hints of the step function.
-* Finally it updates the state of the workflow and persists all model changes to the database upon reaching the
+- Loads the previous steps state from the database.
+- Inspects the step functions signature
+- Finds the arguments in the state and injects them as function arguments to the step function
+- It casts them to the correct type by using the type hints of the step function.
+- Finally it updates the state of the workflow and persists all model changes to the database upon reaching the
`return` of the step function.
### Forms
@@ -904,19 +924,21 @@ subscription with minimal or no impact to the customer.
#### Form _Magic_
+
As mentioned before, forms are dynamically created from the backend. This means, **little to no** frontend coding is
needed to make complex wizard like input forms available to the user. When selecting an action in the UI. The first
thing the frontend does is make an api call to load a form from the backend. The resulting `JSONschema` is parsed
and the correct widgets are loaded in the frontend. Upon submit this is posted to the backend that does all
validation and signals to the user if there are any errors. The following forms are supported:
-* Multiselect
-* Drop-down
-* Text field (restricted)
-* Number (float and dec)
-* Radio
+- Multiselect
+- Drop-down
+- Text field (restricted)
+- Number (float and dec)
+- Radio
## Workflow examples
+
What follows are a few examples of how workflows implement the best common practices implemented by SURF. It
explains in detail what a typical workflow could look like for provision in network element. These examples can be
examined in greater detail by exploring the `.workflows.node` directory.
@@ -945,20 +967,21 @@ def create_node() -> StepList:
1. Collect input from user (`initial_input_form`)
2. Instantiate subscription (`construct_node_model`):
- 1. Create inactive subscription model
- 2. assign user input to subscription
- 3. transition to subscription to provisioning
+ 1. Create inactive subscription model
+ 2. assign user input to subscription
+ 3. transition to subscription to provisioning
3. Register create process for this subscription (`store_process_subscription`)
4. Interact with OSS and/or BSS, in this example
- 1. Administer subscription in IMS (`create_node_in ims`)
- 2. Reserve IP addresses in IPAM (`reserve_loopback_addresses`)
- 3. Provision subscription in the network (`provision_node_in_nrm`)
+ 1. Administer subscription in IMS (`create_node_in ims`)
+ 2. Reserve IP addresses in IPAM (`reserve_loopback_addresses`)
+ 3. Provision subscription in the network (`provision_node_in_nrm`)
5. Transition subscription to active and ‘in sync’ (`@create_workflow`)
As long as every step remains as idempotent as possible, the work can be
divided over fewer or more steps as desired.
#### Input Form
+
The input form is created by subclassing the `FormPage` and add the
input fields together with the type and indication if they are optional
or not. Additional form settings can be changed via the Config class,
@@ -1014,6 +1037,7 @@ PortsChoiceList: TypeAlias = cast(type[Choice], ports_selector(2))
```
#### Extra Validation between dependant fields
+
Validations between multiple fields is also possible by making use of
the Pydantic `@model_validator` decorator that gives access to all
fields. To check if the A and B side of a point-to-point service are not
@@ -1062,13 +1086,13 @@ def modify_node() -> StepList:
1. Collect input from user (`initial_input_form`)
2. Necessary subscription administration (`@modify_workflow`):
- 1. Register modify process for this subscription
- 2. Set subscription ‘out of sync’ to prevent the start of other processes
+ 1. Register modify process for this subscription
+ 2. Set subscription ‘out of sync’ to prevent the start of other processes
3. Transition subscription to Provisioning (`set_status`)
4. Update subscription with the user input
5. Interact with OSS and/or BSS, in this example
- 1. Update subscription in IMS (`update_node_in ims`)
- 2. Update subscription in NRM (`update_node_in nrm`)
+ 1. Update subscription in IMS (`update_node_in ims`)
+ 2. Update subscription in NRM (`update_node_in nrm`)
6. Transition subscription to active (`set_status`)
7. Set subscription ‘in sync’ (`@modify_workflow`)
@@ -1116,15 +1140,15 @@ def terminate_node() -> StepList:
1. Show subscription details and ask user to confirm termination (`initial_input_form`)
2. Necessary subscription administration (`@terminate_workflow`):
- 1. Register terminate process for this subscription
- 2. Set subscription ‘out of sync’ to prevent the start of other processes
+ 1. Register terminate process for this subscription
+ 2. Set subscription ‘out of sync’ to prevent the start of other processes
3. Get subscription and add information for following steps to the State (`load_initial_state`)
4. Interact with OSS and/or BSS, in this example
- 1. Delete node in IMS (`delete_node_in ims`)
- 2. Deprovision node in NRM (`deprovision_node_in_nrm`)
+ 1. Delete node in IMS (`delete_node_in ims`)
+ 2. Deprovision node in NRM (`deprovision_node_in_nrm`)
5. Necessary subscription administration (`@terminate_workflow`)
- 1. Transition subscription to terminated
- 2. Set subscription ‘in sync’
+ 1. Transition subscription to terminated
+ 2. Set subscription ‘in sync’
The initial input form for the terminate workflow is very simple, it
only has to show the details of the subscription:
@@ -1158,13 +1182,13 @@ def validate_l2vpn() -> StepList:
```
1. Necessary subscription administration (`@validate_workflow`):
- 1. Register validate process for this subscription
- 2. Set subscription ‘out of sync’, even when subscription is already out of sync
+ 1. Register validate process for this subscription
+ 2. Set subscription ‘out of sync’, even when subscription is already out of sync
2. One or more steps to validate the subscription against all OSS and BSS:
- 1. Validate subscription against IMS:
- 1. `validate_l2vpn_in_ims`
- 2. `validate_l2vpn_terminations_in_ims`
- 3. `validate_vlans_on_ports_in_ims`
+ 1. Validate subscription against IMS:
+ 1. `validate_l2vpn_in_ims`
+ 2. `validate_l2vpn_terminations_in_ims`
+ 3. `validate_vlans_on_ports_in_ims`
3. Set subscription ‘in sync’ again (`@validate_workflow`)
When one of the validation steps fail, the subscription will stay ‘out
@@ -1207,12 +1231,12 @@ def reconcile_l2vpn() -> StepList:
```
1. Minimal required information of the subscription is collected and consists of the subscriptions
-existing configuration.
+ existing configuration.
2. Necessary subscription administration (`@reconcile_workflow`):
- 1. Register reconcile process for this subscription
- 2. Set subscription ‘out of sync’ to prevent the start of other processes
+ 1. Register reconcile process for this subscription
+ 2. Set subscription ‘out of sync’ to prevent the start of other processes
3. Interact with OSS and/or BSS, in this example
- 1. Update subscription in external systems (OSS and/or BSS) (`update_l2vpn_in_external_systems`)
+ 1. Update subscription in external systems (OSS and/or BSS) (`update_l2vpn_in_external_systems`)
4. Set subscription ‘in sync’ (`@reconcile_workflow`)
Because both a `@modify_workflows` and `@reconcile_workflow` need to have the same update steps for
@@ -1258,7 +1282,7 @@ parameter will be taken into account to decide which one of the
functions need to be execute.
A helper function called `single_dispatch_base()` is used to keep track
-of all registered functions and the type of their first argument. This
+of all registered functions and the type of their first argument. This
allows for more informative error messages when the single dispatch
function is called with an unsupported parameter.
@@ -1465,40 +1489,41 @@ nodes.
### Federation
-WFO and NetBox both use the GraphQL framework Strawberry[^9] which supports Apollo Federation[^8]. This allows to expose both GraphQL backends as a single *supergraph*. WFO can be integrated with any other GraphQL backend that supports[^10] federation and of which you can modify the code. In case of NetBox we don't have direct control over the source code, so we patched it for purposes of demonstration.
+WFO and NetBox both use the GraphQL framework Strawberry[^9] which supports Apollo Federation[^8]. This allows to expose both GraphQL backends as a single _supergraph_. WFO can be integrated with any other GraphQL backend that supports[^10] federation and of which you can modify the code. In case of NetBox we don't have direct control over the source code, so we patched it for purposes of demonstration.
#### Requirements
The following is required to facilitate GraphQL federation on top of WFO and other GraphQL backend(s):
-* WFO must be configured with `FEDERATION_ENABLED=True`
- * [`docker/orchestrator/orchestrator.env`](docker/orchestrator/orchestrator.env)
-* The other backend must also enable federation
- * NetBox: [`docker/netbox/Dockerfile`](docker/netbox/Dockerfile)
-* In both backends set a federation key on the GraphQL types to join
- * WFO: [`graphql_federation.py`](graphql_federation.py)
- * NetBox: [`docker/netbox/patch_federation.py`](docker/netbox/patch_federation.py)
-* Define the supergraph config with both backends
- * [`docker/federation/supergraph-config.yaml`](docker/federation/supergraph-config.yaml)
-* Compile the supergraph schema with rover[^12]
- * `rover-compose` startup service in [`docker-compose.yml`](docker-compose.yml)
-* Run Apollo Router to serve the supergraph
- * `federation` service in [`docker-compose.yml`](docker-compose.yml)
+- WFO must be configured with `FEDERATION_ENABLED=True`
+ - [`docker/orchestrator/orchestrator.env`](docker/orchestrator/orchestrator.env)
+- The other backend must also enable federation
+ - NetBox: [`docker/netbox/Dockerfile`](docker/netbox/Dockerfile)
+- In both backends set a federation key on the GraphQL types to join
+ - WFO: [`graphql_federation.py`](graphql_federation.py)
+ - NetBox: [`docker/netbox/patch_federation.py`](docker/netbox/patch_federation.py)
+- Define the supergraph config with both backends
+ - [`docker/federation/supergraph-config.yaml`](docker/federation/supergraph-config.yaml)
+- Compile the supergraph schema with rover[^12]
+ - `rover-compose` startup service in [`docker-compose.yml`](docker-compose.yml)
+- Run Apollo Router to serve the supergraph
+ - `federation` service in [`docker-compose.yml`](docker-compose.yml)
For more information on federating new GraphQL types, or the existing WFO GraphQL types, please refer to our reference documentation[^11].
#### Example queries
> **Note:**
-> The following queries assume a running `docker-compose` environment with:
-> - Initial seed of NetBox via running a `Netbox bootstrap` Task
-> - Two newly configured nodes
->
+> The following queries assume a running `docker-compose` environment with:
+>
+> - Initial seed of NetBox via running a `Netbox bootstrap` Task
+> - Two newly configured nodes
+>
> See section [Using the example orchestrator](#using-the-example-orchestrator) on how to run Tasks and create nodes in the [Workflow Orchestrator UI](http://localhost:3000/)
-
+
We'll demonstrate how two separate GraphQL queries can now be performed in one federated query.
-**NetBox**: NetBox device details can be queried from the NetBox GraphQL endpoint at
+**NetBox**: NetBox device details can be queried from the NetBox GraphQL endpoint at
http://localhost:8000/graphql/ (be sure to authenticate first with admin/admin in [NetBox](http://localhost:8000/))
```graphql
@@ -1524,9 +1549,7 @@ query GetNetboxDevices {
```graphql
query GetSubscriptions {
- subscriptions(filterBy:
- {field: "type", value: "Node"}
- ) {
+ subscriptions(filterBy: { field: "type", value: "Node" }) {
page {
... on NodeSubscription {
subscriptionId
@@ -1547,9 +1570,7 @@ query GetSubscriptions {
```graphql
query GetEnrichedSubscriptions {
- subscriptions(filterBy:
- {field: "type", value: "Node"}
- ) {
+ subscriptions(filterBy: { field: "type", value: "Node" }) {
page {
... on NodeSubscription {
subscriptionId
@@ -1604,31 +1625,30 @@ Environment variables and orchestrator-core can be overridden for development pu
WFO WorkFlow Orchestrator
-[^1]: M7.3 Common NREN Network Service Product Models -
-https://resources.geant.org/wp-content/uploads/2023/06/M7.3_Common-NREN-Network-Service-Product-Models.pdf
+[^1]:
+ M7.3 Common NREN Network Service Product Models -
+ https://resources.geant.org/wp-content/uploads/2023/06/M7.3_Common-NREN-Network-Service-Product-Models.pdf
-[^2]: Workflow Orchestrator website -
-https://workfloworchestrator.org/orchestrator-core/
+[^2]:
+ Workflow Orchestrator website -
+ https://workfloworchestrator.org/orchestrator-core/
-[^3]: NetBox is a tool for data center infrastructure management and IP
-address management - https://netbox.dev
+[^3]:
+ NetBox is a tool for data center infrastructure management and IP
+ address management - https://netbox.dev
-[^4]: The Python SQL Toolkit and Object Relational Mapper -
-https://www.sqlalchemy.org
+[^4]:
+ The Python SQL Toolkit and Object Relational Mapper -
+ https://www.sqlalchemy.org
[^5]: ASGI server Uvicorn - https://www.uvicorn.org
-
-[^6]: Pydantic is a data validation library for Python -
-https://pydantic.dev/
+[^6]:
+ Pydantic is a data validation library for Python -
+ https://pydantic.dev/
[^7]: Pynetbox Python API - https://github.com/netbox-community/pynetbox
-
[^8]: Apollo Federation - https://www.apollographql.com/docs/federation/
-
[^9]: Strawberry Federation - https://strawberry.rocks/docs/federation/introduction
-
[^10]: Apollo Federation support - https://www.apollographql.com/docs/federation/building-supergraphs/supported-subgraphs
-
[^11]: WFO GraphQL Documentation - https://workfloworchestrator.org/orchestrator-core/reference-docs/graphql/
-
[^12]: Apollo Rover - https://www.apollographql.com/docs/rover/
diff --git a/migrations/versions/schema/2025-08-28_0e8d17ce0f06_reconcile_workflows_l2vpn.py b/migrations/versions/schema/2025-08-28_0e8d17ce0f06_reconcile_workflows_l2vpn.py
index fc540f1..08a919c 100644
--- a/migrations/versions/schema/2025-08-28_0e8d17ce0f06_reconcile_workflows_l2vpn.py
+++ b/migrations/versions/schema/2025-08-28_0e8d17ce0f06_reconcile_workflows_l2vpn.py
@@ -6,12 +6,11 @@
"""
-import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0e8d17ce0f06"
-down_revision = "d946c20663d3"
+down_revision = "bc54616fefcf"
branch_labels = None
depends_on = None
diff --git a/migrations/versions/schema/2025-09-30_a87d11eb8dd1_add_nsistp.py b/migrations/versions/schema/2025-09-30_a87d11eb8dd1_add_nsistp.py
new file mode 100644
index 0000000..3c0d471
--- /dev/null
+++ b/migrations/versions/schema/2025-09-30_a87d11eb8dd1_add_nsistp.py
@@ -0,0 +1,101 @@
+"""Add nsistp product.
+
+Revision ID: a87d11eb8dd1
+Revises: 0e8d17ce0f06
+Create Date: 2025-09-30 15:50:36.882313
+
+"""
+
+from uuid import uuid4
+
+from alembic import op
+from orchestrator.migrations.helpers import create, create_workflow, delete, delete_workflow, ensure_default_workflows
+from orchestrator.targets import Target
+
+# revision identifiers, used by Alembic.
+revision = "a87d11eb8dd1"
+down_revision = "0e8d17ce0f06"
+branch_labels = None
+depends_on = None
+
+new_products = {
+ "products": {
+ "nsistp": {
+ "product_id": uuid4(),
+ "product_type": "Nsistp",
+ "description": "NSISTP",
+ "tag": "NSISTP",
+ "status": "active",
+ "root_product_block": "Nsistp",
+ "fixed_inputs": {},
+ },
+ },
+ "product_blocks": {
+ "Nsistp": {
+ "product_block_id": uuid4(),
+ "description": "nsistp product block",
+ "tag": "NSISTP",
+ "status": "active",
+ "resources": {
+ "topology": "Name of the topology this Service Termination Point is exposed in",
+ "stp_id": "Unique identifier for the Service Termination Point",
+ "stp_description": "Description of the Service Termination Point",
+ "is_alias_in": "Inbound port from the other topology in case this STP is part of a SDP",
+ "is_alias_out": "Outbound port from the other topology in case this STP is part of a SDP",
+ "expose_in_topology": "Whether to actively expose this STP in the topology",
+ "bandwidth": "Maximum bandwidth for the combined set of STP (in Mbps)",
+ },
+ "depends_on_block_relations": [
+ "SAP",
+ ],
+ },
+ },
+ "workflows": {},
+}
+
+new_workflows = [
+ {
+ "name": "create_nsistp",
+ "target": Target.CREATE,
+ "is_task": False,
+ "description": "Create nsistp",
+ "product_type": "Nsistp",
+ },
+ {
+ "name": "modify_nsistp",
+ "target": Target.MODIFY,
+ "is_task": False,
+ "description": "Modify nsistp",
+ "product_type": "Nsistp",
+ },
+ {
+ "name": "terminate_nsistp",
+ "target": Target.TERMINATE,
+ "is_task": False,
+ "description": "Terminate nsistp",
+ "product_type": "Nsistp",
+ },
+ {
+ "name": "validate_nsistp",
+ "target": Target.VALIDATE,
+ "is_task": True,
+ "description": "Validate nsistp",
+ "product_type": "Nsistp",
+ },
+]
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ create(conn, new_products)
+ for workflow in new_workflows:
+ create_workflow(conn, workflow)
+ ensure_default_workflows(conn)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ for workflow in new_workflows:
+ delete_workflow(conn, workflow["name"])
+
+ delete(conn, new_products)
diff --git a/products/__init__.py b/products/__init__.py
index 8d45115..874cf52 100644
--- a/products/__init__.py
+++ b/products/__init__.py
@@ -17,6 +17,7 @@
from products.product_types.core_link import CoreLink
from products.product_types.l2vpn import L2vpn
from products.product_types.node import Node
+from products.product_types.nsistp import Nsistp
from products.product_types.port import Port
SUBSCRIPTION_MODEL_REGISTRY.update(
@@ -30,5 +31,6 @@
"core link 10G": CoreLink,
"core link 100G": CoreLink,
"l2vpn": L2vpn,
+ "nsistp": Nsistp,
}
)
diff --git a/products/product_blocks/nsistp.py b/products/product_blocks/nsistp.py
new file mode 100644
index 0000000..82c4c26
--- /dev/null
+++ b/products/product_blocks/nsistp.py
@@ -0,0 +1,57 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from orchestrator.domain.base import ProductBlockModel
+from orchestrator.types import SubscriptionLifecycle
+from pydantic import computed_field
+
+from products.product_blocks.sap import SAPBlock, SAPBlockInactive, SAPBlockProvisioning
+
+
+class NsistpBlockInactive(ProductBlockModel, product_block_name="Nsistp"):
+ sap: SAPBlockInactive
+ topology: str | None = None
+ stp_id: str | None = None
+ stp_description: str | None = None
+ is_alias_in: str | None = None
+ is_alias_out: str | None = None
+ expose_in_topology: bool | None = None
+ bandwidth: int | None = None
+
+
+class NsistpBlockProvisioning(NsistpBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
+ sap: SAPBlockProvisioning
+ topology: str
+ stp_id: str
+ stp_description: str | None = None
+ is_alias_in: str | None = None
+ is_alias_out: str | None = None
+ expose_in_topology: bool | None = None
+ bandwidth: int | None = None
+
+ @computed_field
+ @property
+ def title(self) -> str:
+ return f"NSISTP {self.stp_id} on {self.sap.title}"
+
+
+class NsistpBlock(NsistpBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
+ sap: SAPBlock
+ topology: str
+ stp_id: str
+ stp_description: str | None = None
+ is_alias_in: str | None = None
+ is_alias_out: str | None = None
+ expose_in_topology: bool | None = None
+ bandwidth: int | None = None
diff --git a/products/product_blocks/port.py b/products/product_blocks/port.py
index b1bf122..b918119 100644
--- a/products/product_blocks/port.py
+++ b/products/product_blocks/port.py
@@ -12,12 +12,12 @@
# limitations under the License.
-from pydantic_forms.types import strEnum
from typing import List
from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle
from pydantic import computed_field
+from pydantic_forms.types import strEnum
from products.product_blocks.node import NodeBlock, NodeBlockInactive, NodeBlockProvisioning
diff --git a/products/product_blocks/sap.py b/products/product_blocks/sap.py
index d0d4518..a465ccb 100644
--- a/products/product_blocks/sap.py
+++ b/products/product_blocks/sap.py
@@ -27,7 +27,7 @@ class SAPBlockInactive(ProductBlockModel, product_block_name="SAP"):
class SAPBlockProvisioning(SAPBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
port: PortBlockProvisioning
- vlan: int
+ vlan: int # TODO: refactor to CustomVlanRanges together with L2VPN product and workflow
ims_id: int | None = None
@computed_field # type: ignore[misc]
@@ -38,5 +38,5 @@ def title(self) -> str:
class SAPBlock(SAPBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
port: PortBlock
- vlan: int
+ vlan: int # TODO: refactor to CustomVlanRanges together with L2VPN product and workflow
ims_id: int
diff --git a/products/product_types/nsistp.py b/products/product_types/nsistp.py
new file mode 100644
index 0000000..d7b20ca
--- /dev/null
+++ b/products/product_types/nsistp.py
@@ -0,0 +1,35 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from orchestrator.domain.base import SubscriptionModel
+from orchestrator.types import SubscriptionLifecycle
+
+from products.product_blocks.nsistp import NsistpBlock, NsistpBlockInactive, NsistpBlockProvisioning
+from workflows.nsistp.shared.shared import CustomVlanRanges
+
+
+class NsistpInactive(SubscriptionModel, is_base=True):
+ nsistp: NsistpBlockInactive
+
+
+class NsistpProvisioning(NsistpInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
+ nsistp: NsistpBlockProvisioning
+
+
+class Nsistp(NsistpProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
+ nsistp: NsistpBlock
+
+ @property
+ def vlan_range(self) -> CustomVlanRanges:
+ return self.nsistp.sap.vlan
diff --git a/products/services/description.py b/products/services/description.py
index d4b58ef..df6f760 100644
--- a/products/services/description.py
+++ b/products/services/description.py
@@ -21,6 +21,7 @@
from products.product_types.core_link import CoreLinkProvisioning
from products.product_types.l2vpn import L2vpnProvisioning
from products.product_types.node import NodeProvisioning
+from products.product_types.nsistp import NsistpProvisioning
from products.product_types.port import PortProvisioning
from utils.singledispatch import single_dispatch_base
@@ -79,3 +80,14 @@ def _(l2vpn: L2vpnProvisioning) -> str:
f"{l2vpn.virtual_circuit.speed} Mbit/s "
f"({'-'.join(sorted(list(set([sap.port.node.node_name for sap in l2vpn.virtual_circuit.saps]))))})"
)
+
+
+@description.register
+def _(nsistp: NsistpProvisioning) -> str:
+ return (
+ f"{nsistp.product.tag} "
+ f"{nsistp.nsistp.stp_id} "
+ f"topology {nsistp.nsistp.topology} "
+ f"{nsistp.nsistp.sap.port.node.node_name} "
+ f"{nsistp.nsistp.bandwidth} Mbit/s"
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 824d684..18cf011 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,42 @@ line-length = 120
[tool.isort]
profile = "black"
+line_length = 120
-[tool.flake8]
-max-line-length = 120
+[tool.ruff]
+# Exclude a variety of commonly ignored directories.
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".ipynb_checkpoints",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pyenv",
+ ".pytest_cache",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ ".vscode",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "site-packages",
+ "venv",
+]
+
+# Same as Black.
+line-length = 120
+indent-width = 4
+
+# Assume Python 3.9
+target-version = "py313"
diff --git a/templates/nsistp.yaml b/templates/nsistp.yaml
new file mode 100644
index 0000000..ab1ac0a
--- /dev/null
+++ b/templates/nsistp.yaml
@@ -0,0 +1,61 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#
+# This file describes the "L2VPN" product
+#
+config:
+ summary_forms: true
+name: nsistp
+type: Nsistp
+tag: NSISTP
+description: "NSISTP"
+product_blocks:
+ - name: nsistp
+ type: Nsistp
+ tag: NSISTP
+ description: "nsistp product block"
+ fields:
+ - name: sap
+ type: SAP
+ description: "NSI STP service access points"
+ required: provisioning
+ - name: topology
+ description: "Name of the topology this Service Termination Point is exposed in"
+ type: str
+ required: provisioning
+ modifiable:
+ - name: stp_id
+ description: "Unique identifier for the Service Termination Point"
+ type: str
+ required: provisioning
+ - name: stp_description
+ description: "Description of the Service Termination Point"
+ type: str
+ modifiable:
+ - name: is_alias_in
+ description: "Unique identifier for the Service Termination Point"
+ type: str
+ modifiable:
+ - name: is_alias_out
+ description: "Outbound port from the other topology in case this STP is part of a SDP"
+ type: str
+ modifiable:
+ - name: expose_in_topology
+ description: "Whether to actively expose this STP in the topology"
+ type: bool
+ modifiable:
+ - name: bandwidth
+ description: "Maximum bandwidth for the combined set of STP (in Mbps)"
+ type: int
+ modifiable:
diff --git a/translations/en-GB.json b/translations/en-GB.json
index 4e4ca4d..12b887b 100644
--- a/translations/en-GB.json
+++ b/translations/en-GB.json
@@ -1,63 +1,68 @@
{
- "forms" : {
- "fields" : {
- "role_id" : "Node role",
- "role_id_info" : "Functional role of the node in the network",
- "type_id" : "Node type",
- "type_id_info" : "Hardware make and model",
- "site_id" : "Site",
- "site_id_info" : "Location of the node",
- "node_status" : "Node status",
- "node_status_info" : "Operational status of the node",
- "node_name" : "Node name",
- "node_name_info" : "Unique name of node in IMS",
- "node_description" : "Node description",
- "node_description_info" : "Description of the node",
- "node_subscription_id" : "Node",
- "port_ims_id" : "Port",
- "port_ims_id_info" : "Free port on node",
- "port_description" : "Port description",
- "port_description_info" : "Description of the port",
- "port_mode" : "Port mode",
- "port_mode_info" : "Mode of the port (tagged/untagged/link member)",
- "auto_negotiation" : "Auto-Negotiation",
- "auto_negotiation_info" : "Enable Ethernet Auto-Negotiation?",
- "lldp" : "LLDP",
- "lldp_info" : "Enable Link Layer Discovery Protocol?",
- "number_of_ports" : "Number of ports",
- "speed" : "Speed",
- "speed_info" : "Speed in Mbit/s",
- "speed_policer" : "Speed policer",
- "speed_policer_info" : "Enforce the speed?",
- "ports" : "Ports",
- "vlan" : "VLAN",
- "node_subscription_id_a" : "A side node",
- "node_subscription_id_b" : "B side node",
- "port_ims_id_a" : "A side port",
- "port_ims_id_b" : "b side port",
- "under_maintenance" : "Maintenance",
- "under_maintenance_info" : "Enable maintenance mode?",
- "annihilate": "Are you sure you want to wipe the complete administration from Netbox?",
- "annihilate_info": "To continue, you have to check this box"
- }
- },
- "workflow" : {
- "create_core_link" : "Create core_link",
- "create_l2vpn" : "Create l2vpn",
- "create_node" : "Create node",
- "create_port" : "Create port",
- "modify_core_link" : "Modify core_link",
- "modify_l2vpn" : "Modify l2vpn",
- "modify_node" : "Modify node",
- "modify_port" : "Modify port",
- "terminate_core_link" : "Terminate core_link",
- "terminate_l2vpn" : "Terminate l2vpn",
- "terminate_node" : "Terminate node",
- "terminate_port" : "Terminate port",
- "validate_core_link" : "Validate core_link",
- "validate_l2vpn" : "Validate l2vpn",
- "validate_node" : "Validate node",
- "validate_port" : "Validate port",
- "modify_sync_ports" : "Sync ports with IMS"
- }
+ "forms": {
+ "fields": {
+ "annihilate": "Are you sure you want to wipe the complete administration from Netbox?",
+ "annihilate_info": "To continue, you have to check this box",
+ "auto_negotiation": "Auto-Negotiation",
+ "auto_negotiation_info": "Enable Ethernet Auto-Negotiation?",
+ "lldp": "LLDP",
+ "lldp_info": "Enable Link Layer Discovery Protocol?",
+ "node_description": "Node description",
+ "node_description_info": "Description of the node",
+ "node_name": "Node name",
+ "node_name_info": "Unique name of node in IMS",
+ "node_status": "Node status",
+ "node_status_info": "Operational status of the node",
+ "node_subscription_id": "Node",
+ "node_subscription_id_a": "A side node",
+ "node_subscription_id_b": "B side node",
+ "number_of_ports": "Number of ports",
+ "nsistp_settings": "Network Service Interface Service Termination Point settings",
+ "port_description": "Port description",
+ "port_description_info": "Description of the port",
+ "port_ims_id": "Port",
+ "port_ims_id_a": "A side port",
+ "port_ims_id_b": "b side port",
+ "port_ims_id_info": "Free port on node",
+ "port_mode": "Port mode",
+ "port_mode_info": "Mode of the port (tagged/untagged/link member)",
+ "ports": "Ports",
+ "role_id": "Node role",
+ "role_id_info": "Functional role of the node in the network",
+ "site_id": "Site",
+ "site_id_info": "Location of the node",
+ "speed": "Speed",
+ "speed_info": "Speed in Mbit/s",
+ "speed_policer": "Speed policer",
+ "speed_policer_info": "Enforce the speed?",
+ "type_id": "Node type",
+ "type_id_info": "Hardware make and model",
+ "under_maintenance": "Maintenance",
+ "under_maintenance_info": "Enable maintenance mode?",
+ "vlan": "VLAN"
+ }
+ },
+ "workflow": {
+ "create_core_link": "Create core_link",
+ "create_l2vpn": "Create l2vpn",
+ "create_node": "Create node",
+ "create_nsistp": "Create nsistp",
+ "create_port": "Create port",
+ "modify_core_link": "Modify core_link",
+ "modify_l2vpn": "Modify l2vpn",
+ "modify_node": "Modify node",
+ "modify_nsistp": "Modify nsistp",
+ "modify_port": "Modify port",
+ "modify_sync_ports": "Sync ports with IMS",
+ "terminate_core_link": "Terminate core_link",
+ "terminate_l2vpn": "Terminate l2vpn",
+ "terminate_node": "Terminate node",
+ "terminate_nsistp": "Terminate nsistp",
+ "terminate_port": "Terminate port",
+ "validate_core_link": "Validate core_link",
+ "validate_l2vpn": "Validate l2vpn",
+ "validate_node": "Validate node",
+ "validate_nsistp": "Validate nsistp",
+ "validate_port": "Validate port"
+ }
}
diff --git a/uv.lock b/uv.lock
index e041399..52ceba5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -206,14 +206,14 @@ wheels = [
[[package]]
name = "deepdiff"
-version = "8.0.1"
+version = "8.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "orderly-set" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/62/ba/aced1d6a7d988ca1b6f9b274faed7dafc7356a733e45a457819bddcf2dbc/deepdiff-8.0.1.tar.gz", hash = "sha256:245599a4586ab59bb599ca3517a9c42f3318ff600ded5e80a3432693c8ec3c4b", size = 427721, upload-time = "2024-08-28T20:24:09.286Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/06/46/01673060e83277a863baf0909b387cd809865cba2d5e7213db76516bedd9/deepdiff-8.0.1-py3-none-any.whl", hash = "sha256:42e99004ce603f9a53934c634a57b04ad5900e0d8ed0abb15e635767489cbc05", size = 82741, upload-time = "2024-08-28T20:24:07.645Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" },
]
[[package]]
@@ -272,7 +272,7 @@ dependencies = [
[package.metadata]
requires-dist = [
- { name = "deepdiff", specifier = "==8.0.1" },
+ { name = "deepdiff", specifier = "==8.6.1" },
{ name = "orchestrator-core", specifier = "==4.0.4" },
{ name = "pynetbox", specifier = "==7.4.1" },
{ name = "rich", specifier = "==13.9.4" },
@@ -644,11 +644,11 @@ wheels = [
[[package]]
name = "orderly-set"
-version = "5.2.2"
+version = "5.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c8/71/5408fee86ce5408132a3ece6eff61afa2c25d5b37cd76bc100a9a4a4d8dd/orderly_set-5.2.2.tar.gz", hash = "sha256:52a18b86aaf3f5d5a498bbdb27bf3253a4e5c57ab38e5b7a56fa00115cd28448", size = 19103, upload-time = "2024-08-28T20:12:57.618Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/69/71/6f9554919da608cb5bcf709822a9644ba4785cc7856e01ea375f6d808774/orderly_set-5.2.2-py3-none-any.whl", hash = "sha256:f7a37c95a38c01cdfe41c3ffb62925a318a2286ea0a41790c057fc802aec54da", size = 11621, upload-time = "2024-08-28T20:12:56.407Z" },
+ { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" },
]
[[package]]
diff --git a/workflows/__init__.py b/workflows/__init__.py
index 4f865bc..c80d4d2 100644
--- a/workflows/__init__.py
+++ b/workflows/__init__.py
@@ -40,5 +40,11 @@
LazyWorkflowInstance("workflows.l2vpn.modify_l2vpn", "reconcile_l2vpn")
+LazyWorkflowInstance("workflows.nsistp.create_nsistp", "create_nsistp")
+LazyWorkflowInstance("workflows.nsistp.modify_nsistp", "modify_nsistp")
+LazyWorkflowInstance("workflows.nsistp.terminate_nsistp", "terminate_nsistp")
+LazyWorkflowInstance("workflows.nsistp.validate_nsistp", "validate_nsistp")
+
+
LazyWorkflowInstance("workflows.tasks.bootstrap_netbox", "task_bootstrap_netbox")
LazyWorkflowInstance("workflows.tasks.wipe_netbox", "task_wipe_netbox")
diff --git a/workflows/l2vpn/create_l2vpn.py b/workflows/l2vpn/create_l2vpn.py
index 4988f0c..4ab1848 100644
--- a/workflows/l2vpn/create_l2vpn.py
+++ b/workflows/l2vpn/create_l2vpn.py
@@ -12,7 +12,6 @@
# limitations under the License.
-from pydantic_forms.types import UUIDstr
import uuid
from random import randrange
from typing import TypeAlias, cast
@@ -24,7 +23,7 @@
from orchestrator.workflows.utils import create_workflow
from pydantic import ConfigDict
from pydantic_forms.core import FormPage
-from pydantic_forms.types import FormGenerator, State
+from pydantic_forms.types import FormGenerator, State, UUIDstr
from pydantic_forms.validators import Choice
from products.product_blocks.sap import SAPBlockInactive
diff --git a/workflows/node/create_node.py b/workflows/node/create_node.py
index 4581f70..fb6fe7a 100644
--- a/workflows/node/create_node.py
+++ b/workflows/node/create_node.py
@@ -12,22 +12,21 @@
# limitations under the License.
-from pydantic_forms.types import UUIDstr
-import uuid
import json
+import uuid
from random import randrange
from typing import TypeAlias, cast
from orchestrator.services.products import get_product_by_id
from orchestrator.targets import Target
from orchestrator.types import SubscriptionLifecycle
+from orchestrator.utils.json import json_dumps
from orchestrator.workflow import StepList, begin, step
from orchestrator.workflows.steps import store_process_subscription
from orchestrator.workflows.utils import create_workflow
-from orchestrator.utils.json import json_dumps
from pydantic import ConfigDict
from pydantic_forms.core import FormPage
-from pydantic_forms.types import FormGenerator, State
+from pydantic_forms.types import FormGenerator, State, UUIDstr
from pydantic_forms.validators import Choice, Label
from products.product_blocks.shared.types import NodeStatus
diff --git a/workflows/nsistp/create_nsistp.py b/workflows/nsistp/create_nsistp.py
new file mode 100644
index 0000000..6cfe0b1
--- /dev/null
+++ b/workflows/nsistp/create_nsistp.py
@@ -0,0 +1,151 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import uuid
+from typing import Annotated, TypeAlias, cast
+
+import structlog
+from orchestrator.forms import FormPage
+from orchestrator.forms.validators import Divider, Label
+from orchestrator.targets import Target
+from orchestrator.types import SubscriptionLifecycle
+from orchestrator.workflow import StepList, begin, step
+from orchestrator.workflows.steps import store_process_subscription
+from orchestrator.workflows.utils import create_workflow
+from pydantic import AfterValidator, ConfigDict, model_validator
+from pydantic_forms.types import FormGenerator, State, UUIDstr
+from pydantic_forms.validators import Choice
+
+from products.product_types.nsistp import NsistpInactive, NsistpProvisioning
+from products.services.description import description
+from products.services.netbox.netbox import build_payload
+from services import netbox
+from workflows.nsistp.shared.forms import (
+ IsAlias,
+ ServiceSpeed,
+ StpDescription,
+ StpId,
+ Topology,
+ nsistp_fill_sap,
+ port_selector,
+ validate_both_aliases_empty_or_not,
+)
+from workflows.nsistp.shared.vlan import validate_vlan, validate_vlan_not_in_use
+from workflows.shared import create_summary_form
+
+logger = structlog.get_logger(__name__)
+
+
+def initial_input_form_generator(product_name: str) -> FormGenerator:
+ PortChoiceList: TypeAlias = cast(type[Choice], port_selector())
+
+ class CreateNsiStpForm(FormPage):
+ model_config = ConfigDict(title=product_name)
+
+ nsistp_settings: Label
+
+ port: PortChoiceList
+ # TODO: change to support CustomVlanRanges
+ vlan: Annotated[
+ int,
+ AfterValidator(validate_vlan),
+ AfterValidator(validate_vlan_not_in_use),
+ ]
+
+ divider_1: Divider
+
+ topology: Topology
+ stp_id: StpId
+ stp_description: StpDescription | None = None
+ is_alias_in: IsAlias | None = None
+ is_alias_out: IsAlias | None = None
+ expose_in_topology: bool = True
+ bandwidth: ServiceSpeed
+
+ @model_validator(mode="after")
+ def validate_is_alias_in_out(self) -> "CreateNsiStpForm":
+ validate_both_aliases_empty_or_not(self.is_alias_in, self.is_alias_out)
+ return self
+
+ user_input = yield CreateNsiStpForm
+ user_input_dict = user_input.dict()
+
+ summary_fields = [
+ "port",
+ "vlan",
+ "topology",
+ "stp_id",
+ "stp_description",
+ "is_alias_in",
+ "is_alias_out",
+ "expose_in_topology",
+ "bandwidth",
+ ]
+ yield from create_summary_form(user_input_dict, product_name, summary_fields)
+
+ return user_input_dict
+
+
+@step("Construct Subscription model")
+def construct_nsistp_model(
+ product: UUIDstr,
+ port: UUIDstr,
+ vlan: int,
+ topology: str,
+ stp_id: str,
+ stp_description: str | None,
+ is_alias_in: str | None,
+ is_alias_out: str | None,
+ expose_in_topology: bool | None,
+ bandwidth: int | None,
+) -> State:
+ nsistp = NsistpInactive.from_product_id(
+ product_id=product,
+ customer_id=str(uuid.uuid4()),
+ status=SubscriptionLifecycle.INITIAL,
+ )
+ nsistp.nsistp.topology = topology
+ nsistp.nsistp.stp_id = stp_id
+ nsistp.nsistp.stp_description = stp_description
+ nsistp.nsistp.is_alias_in = is_alias_in
+ nsistp.nsistp.is_alias_out = is_alias_out
+ nsistp.nsistp.expose_in_topology = expose_in_topology
+ nsistp.nsistp.bandwidth = bandwidth
+
+ # TODO: change to support CustomVlanRanges
+ vlan_int = int(vlan) if not isinstance(vlan, int) else vlan
+
+ nsistp_fill_sap(nsistp, port, vlan_int)
+
+ nsistp = NsistpProvisioning.from_other_lifecycle(nsistp, SubscriptionLifecycle.PROVISIONING)
+ nsistp.description = description(nsistp)
+
+ return {
+ "subscription": nsistp,
+ "subscription_id": nsistp.subscription_id, # necessary to be able to use older generic step functions
+ "subscription_description": nsistp.description,
+ }
+
+
+@step("Create VLANs in IMS (Netbox)")
+def ims_create_vlans(subscription: NsistpProvisioning) -> State:
+ payload = build_payload(subscription.nsistp.sap, subscription)
+ subscription.nsistp.sap.ims_id = netbox.create(payload)
+
+ return {"subscription": subscription, "payloads": [payload]}
+
+
+@create_workflow("Create nsistp", initial_input_form=initial_input_form_generator)
+def create_nsistp() -> StepList:
+ return begin >> construct_nsistp_model >> store_process_subscription(Target.CREATE) >> ims_create_vlans
diff --git a/workflows/nsistp/modify_nsistp.py b/workflows/nsistp/modify_nsistp.py
new file mode 100644
index 0000000..86ed10b
--- /dev/null
+++ b/workflows/nsistp/modify_nsistp.py
@@ -0,0 +1,100 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import structlog
+from orchestrator.forms import FormPage
+from orchestrator.forms.validators import Divider
+from orchestrator.types import SubscriptionLifecycle
+from orchestrator.workflow import StepList, begin, step
+from orchestrator.workflows.steps import set_status
+from orchestrator.workflows.utils import modify_workflow
+from pydantic_forms.types import FormGenerator, State, UUIDstr
+from pydantic_forms.validators import read_only_field
+
+from products.product_types.nsistp import Nsistp, NsistpProvisioning
+from products.services.description import description
+from workflows.nsistp.shared.forms import IsAlias, ServiceSpeed, StpDescription, Topology
+from workflows.shared import modify_summary_form
+
+logger = structlog.get_logger(__name__)
+
+
+def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
+ subscription = Nsistp.from_subscription(subscription_id)
+ nsistp = subscription.nsistp
+
+ class ModifyNsistpForm(FormPage):
+ stp_id: read_only_field(nsistp.stp_id)
+
+ divider_1: Divider
+
+ topology: Topology = nsistp.topology
+ stp_description: StpDescription | None = nsistp.stp_description
+ is_alias_in: IsAlias | None = nsistp.is_alias_in
+ is_alias_out: IsAlias | None = nsistp.is_alias_out
+ expose_in_topology: bool | None = nsistp.expose_in_topology
+ bandwidth: ServiceSpeed | None = nsistp.bandwidth
+
+ user_input = yield ModifyNsistpForm
+ user_input_dict = user_input.dict()
+
+ summary_fields = [
+ "topology",
+ "stp_id",
+ "stp_description",
+ "is_alias_in",
+ "is_alias_out",
+ "expose_in_topology",
+ "bandwidth",
+ ]
+ yield from modify_summary_form(user_input_dict, subscription.nsistp, summary_fields)
+
+ return user_input_dict | {"subscription": subscription}
+
+
+@step("Update subscription")
+def update_subscription(
+ subscription: NsistpProvisioning,
+ topology: str,
+ stp_description: str | None,
+ is_alias_in: str | None,
+ is_alias_out: str | None,
+ expose_in_topology: bool | None,
+ bandwidth: int | None,
+) -> State:
+ subscription.nsistp.topology = topology
+ subscription.nsistp.stp_description = stp_description
+ subscription.nsistp.is_alias_in = is_alias_in
+ subscription.nsistp.is_alias_out = is_alias_out
+ subscription.nsistp.expose_in_topology = expose_in_topology
+ subscription.nsistp.bandwidth = bandwidth
+
+ return {"subscription": subscription}
+
+
+@step("Update subscription description")
+def update_subscription_description(subscription: Nsistp) -> State:
+ subscription.description = description(subscription)
+ return {"subscription": subscription}
+
+
+@modify_workflow("Modify nsistp", initial_input_form=initial_input_form_generator)
+def modify_nsistp() -> StepList:
+ return (
+ begin
+ >> set_status(SubscriptionLifecycle.PROVISIONING)
+ >> update_subscription
+ >> update_subscription_description
+ >> set_status(SubscriptionLifecycle.ACTIVE)
+ )
diff --git a/workflows/nsistp/shared/__init__.py b/workflows/nsistp/shared/__init__.py
new file mode 100644
index 0000000..0da72f0
--- /dev/null
+++ b/workflows/nsistp/shared/__init__.py
@@ -0,0 +1,12 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/workflows/nsistp/shared/forms.py b/workflows/nsistp/shared/forms.py
new file mode 100644
index 0000000..3b4f9e0
--- /dev/null
+++ b/workflows/nsistp/shared/forms.py
@@ -0,0 +1,199 @@
+# Copyright 2019-2024 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import re
+from collections.abc import Iterator
+from datetime import datetime
+from functools import partial
+from typing import Annotated, Any
+from uuid import UUID
+
+from annotated_types import BaseMetadata, Ge, Le
+from orchestrator.db import ProductTable, db
+from orchestrator.db.models import SubscriptionTable
+from orchestrator.domain.base import SubscriptionModel
+from orchestrator.types import SubscriptionLifecycle
+from pydantic import AfterValidator, Field, ValidationInfo
+from pydantic_forms.types import UUIDstr
+from pydantic_forms.validators import Choice
+from sqlalchemy import select
+from typing_extensions import Doc
+
+from products.product_blocks.port import PortMode
+from products.product_types.nsistp import Nsistp, NsistpInactive
+from workflows.nsistp.shared.shared import MAX_SPEED_POSSIBLE, CustomVlanRanges
+from workflows.shared import subscriptions_by_product_type_and_instance_value
+
+TOPOLOGY_REGEX = r"^[-a-z0-9+,.;=_]+$"
+STP_ID_REGEX = r"^[-a-z0-9+,.;=_:]+$"
+NURN_REGEX = r"^urn:ogf:network:([^:]+):([0-9]+):([a-z0-9+,-.:;_!$()*@~&]*)$"
+FQDN_REQEX = (
+ r"^(?!.{255}|.{253}[^.])([a-z0-9](?:[-a-z-0-9]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?[.]?$"
+)
+VALID_DATE_FORMATS = {
+ 4: '%Y',
+ 6: '%Y%m',
+ 8: '%Y%m%d',
+}
+
+
+
+def port_selector() -> type[Choice]:
+ port_subscriptions = subscriptions_by_product_type_and_instance_value(
+ "Port", "port_mode", PortMode.TAGGED, [SubscriptionLifecycle.ACTIVE]
+ )
+ ports = {
+ str(subscription.subscription_id): subscription.description
+ for subscription in sorted(port_subscriptions, key=lambda port: port.description)
+ }
+ return Choice("Port", zip(ports.keys(), ports.items()))
+
+
+def is_fqdn(hostname: str) -> bool:
+ return re.match(FQDN_REQEX, hostname, re.IGNORECASE) is not None
+
+
+def valid_date(date: str) -> tuple[bool, str | None]:
+ if not (date_format := VALID_DATE_FORMATS.get(len(date))):
+ return False, f"Invalid date length, expected one of: {list(VALID_DATE_FORMATS)}"
+
+ try:
+ _ = datetime.strptime(date, date_format)
+ return True, None
+ except ValueError as exc:
+ return False, f"Invalid date for format {date_format!r}: {exc}"
+
+
+def valid_nurn(nurn: str) -> tuple[bool, str | None]:
+ if not (match := re.match(NURN_REGEX, nurn, re.IGNORECASE)):
+ return False, "not a valid NSI STP identifier (urn:ogf:network:...)"
+
+ hostname = match.group(1)
+ if not is_fqdn(hostname):
+ return False, f"{hostname} is not a valid fqdn"
+
+ date = match.group(2)
+ valid, message = valid_date(date)
+
+ return valid, message
+
+
+def validate_regex(
+ regex: str,
+ message: str,
+ field: str | None,
+) -> str | None:
+ if field is None:
+ return field
+
+ if not re.match(regex, field, re.IGNORECASE):
+ raise ValueError(f"{message} must match: {regex}")
+
+ return field
+
+
+def _get_nsistp_subscriptions(subscription_id: UUID | None) -> Iterator[Nsistp]:
+ query = (
+ select(SubscriptionTable.subscription_id)
+ .join(ProductTable)
+ .filter(
+ ProductTable.product_type == "NSISTP",
+ SubscriptionTable.status == SubscriptionLifecycle.ACTIVE,
+ SubscriptionTable.subscription_id != subscription_id,
+ )
+ )
+ result = db.session.scalars(query).all()
+ return (Nsistp.from_subscription(subscription_id) for subscription_id in result)
+
+
+def validate_stp_id_uniqueness(subscription_id: UUID | None, stp_id: str, info: ValidationInfo) -> str:
+ values = info.data
+
+ customer_id = values.get("customer_id")
+ topology = values.get("topology")
+
+ if customer_id and topology:
+
+ def is_not_unique(nsistp: Nsistp) -> bool:
+ return (
+ nsistp.settings.stp_id.casefold() == stp_id.casefold()
+ and nsistp.settings.topology.casefold() == topology.casefold()
+ )
+
+ subscriptions = _get_nsistp_subscriptions(subscription_id)
+ if any(is_not_unique(nsistp) for nsistp in subscriptions):
+ raise ValueError(f"STP identifier `{stp_id}` already exists for topology `{topology}`")
+
+ return stp_id
+
+
+def validate_both_aliases_empty_or_not(is_alias_in: str | None, is_alias_out: str | None) -> None:
+ if bool(is_alias_in) != bool(is_alias_out):
+ raise ValueError("NSI inbound and outbound isAlias should either both have a value or be empty")
+
+
+def validate_nurn(nurn: str | None) -> str | None:
+ if nurn:
+ valid, message = valid_nurn(nurn)
+ if not valid:
+ raise ValueError(message)
+
+ return nurn
+
+
+def nsistp_fill_sap(subscription: NsistpInactive, subscription_id: UUIDstr, vlan: CustomVlanRanges | int) -> None:
+ subscription.nsistp.sap.vlan = vlan
+ subscription.nsistp.sap.port = SubscriptionModel.from_subscription(subscription_id).port # type: ignore
+
+
+def merge_uniforms(schema: dict[str, Any], *, to_merge: dict[str, Any]) -> None:
+ schema["uniforms"] = schema.get("uniforms", {}) | to_merge
+
+
+def uniforms_field(to_merge: dict[str, Any]) -> BaseMetadata:
+ return Field(json_schema_extra=partial(merge_uniforms, to_merge=to_merge))
+
+
+Topology = Annotated[
+ str,
+ AfterValidator(partial(validate_regex, TOPOLOGY_REGEX, "Topology")),
+ Doc("topology string may only consist of characters from the set [-a-z+,.;=_]"),
+]
+
+StpId = Annotated[
+ str,
+ AfterValidator(partial(validate_regex, STP_ID_REGEX, "STP identifier")),
+ Doc("must be unique along the set of NSISTP's in the same TOPOLOGY"),
+]
+
+StpDescription = Annotated[
+ str,
+ AfterValidator(partial(validate_regex, r"^[^<>&]*$", "STP description")),
+ Doc("STP description may not contain characters from the set [<>&]"),
+]
+
+IsAlias = Annotated[
+ str,
+ AfterValidator(validate_nurn),
+ Doc("ISALIAS conform https://www.ogf.org/documents/GFD.202.pdf"),
+]
+
+Bandwidth = Annotated[
+ int,
+ Ge(1),
+ Le(MAX_SPEED_POSSIBLE),
+ Doc(f"Bandwidth between {1} and {MAX_SPEED_POSSIBLE}"),
+]
+
+ServiceSpeed = Bandwidth
diff --git a/workflows/nsistp/shared/shared.py b/workflows/nsistp/shared/shared.py
new file mode 100644
index 0000000..4dea7f7
--- /dev/null
+++ b/workflows/nsistp/shared/shared.py
@@ -0,0 +1,53 @@
+# Copyright 2019-2024 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from collections.abc import Callable
+from enum import StrEnum
+from uuid import UUID
+
+import structlog
+from nwastdlib.vlans import VlanRanges
+from orchestrator.db import SubscriptionTable
+from pydantic import GetJsonSchemaHandler
+from pydantic.json_schema import JsonSchemaValue
+from pydantic_core import CoreSchema
+
+logger = structlog.get_logger(__name__)
+
+GetSubscriptionByIdFunc = Callable[[UUID], SubscriptionTable]
+
+MAX_SPEED_POSSIBLE = 400_000
+
+
+# TODO: remove unneeded PortTag class
+class PortTag(StrEnum):
+ SP = "SP"
+ AGGSP = "AGGSP"
+ MSC = "MSC"
+ IRBSP = "IRBSP"
+
+
+# Custom VlanRanges needed to avoid matching conflict with SURF orchestrator-ui components
+class CustomVlanRanges(VlanRanges):
+ def __str__(self) -> str:
+ # `range` objects have an exclusive `stop`. VlanRanges is expressed using terms that use an inclusive stop,
+ # which is one less then the exclusive one we use for the internal representation. Hence the `-1`
+ return ", ".join(str(vr.start) if len(vr) == 1 else f"{vr.start}-{vr.stop - 1}" for vr in self._vlan_ranges)
+
+ @classmethod
+ def __get_pydantic_json_schema__(cls, core_schema_: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
+ parent_schema = super().__get_pydantic_json_schema__(core_schema_, handler)
+ parent_schema["format"] = "custom-vlan"
+
+ return parent_schema
diff --git a/workflows/nsistp/shared/vlan.py b/workflows/nsistp/shared/vlan.py
new file mode 100644
index 0000000..9e457da
--- /dev/null
+++ b/workflows/nsistp/shared/vlan.py
@@ -0,0 +1,168 @@
+# Copyright 2019-2024 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import operator
+from uuid import UUID
+
+import structlog
+from orchestrator.db import (
+ ResourceTypeTable,
+ SubscriptionInstanceRelationTable,
+ SubscriptionInstanceTable,
+ SubscriptionInstanceValueTable,
+ SubscriptionTable,
+ db,
+)
+from orchestrator.services import subscriptions
+from pydantic_core.core_schema import ValidationInfo
+from pydantic_forms.types import State, UUIDstr
+from sqlalchemy import select
+
+from products.product_blocks.port import PortMode
+from workflows.nsistp.shared.shared import CustomVlanRanges, PortTag
+
+logger = structlog.get_logger(__name__)
+
+
+# TODO: remove unneeded _get_port_mode()
+def _get_port_mode(subscription: SubscriptionTable) -> PortMode:
+ if subscription.product.tag in [PortTag.AGGSP + PortTag.SP]:
+ return subscription.port_mode
+ return PortMode.TAGGED
+
+
+def validate_vlan(vlan: CustomVlanRanges, info: ValidationInfo) -> CustomVlanRanges:
+ # We assume an empty string is untagged and thus 0
+ if not vlan:
+ vlan = CustomVlanRanges(0)
+
+ subscription_id = info.data.get("port_id")
+ if not subscription_id:
+ return vlan
+
+ subscription = subscriptions.get_subscription(subscription_id, model=SubscriptionTable)
+
+ port_mode = _get_port_mode(subscription)
+
+ if port_mode == PortMode.TAGGED and vlan == CustomVlanRanges(0):
+ raise ValueError(f"{port_mode} {subscription.product.tag} must have a vlan")
+ elif port_mode == PortMode.UNTAGGED and vlan != CustomVlanRanges(0): # noqa: RET506
+ raise ValueError(f"{port_mode} {subscription.product.tag} can not have a vlan")
+
+ return vlan
+
+
+def validate_vlan_not_in_use(vlan: int, info: ValidationInfo) -> int | CustomVlanRanges:
+ """Wrapper for check_vlan_in_use to work with AfterValidator."""
+ # For single form validation, we don't have a 'current' list, so pass empty list
+ current: list[State] = []
+ return check_vlan_already_used(current, vlan, info)
+
+
+def check_vlan_already_used(
+ current: list[State], vlan: int | CustomVlanRanges, info: ValidationInfo
+) -> int | CustomVlanRanges:
+ """Check if vlan value is already in use by a subscription.
+
+ Args:
+ current: List of current form states, used to filter out self from used vlans.
+ v: Vlan range of the form input.
+ info: validation info, contains other fields in info.data
+
+ Returns: input value if no errors
+ """
+ if not (subscription_id := info.data.get("subscription_id")):
+ return vlan
+
+ used_vlans = find_allocated_vlans(subscription_id)
+
+ # Remove currently chosen vlans for this port to prevent tripping on in used by itself
+ current_selected_vlan_ranges: list[str] = []
+ if current:
+ current_selected_service_port = filter(lambda c: str(c["subscription_id"]) == str(subscription_id), current)
+ current_selected_vlans = list(map(operator.itemgetter("vlan"), current_selected_service_port))
+ for current_selected_vlan in current_selected_vlans:
+ # We assume an empty string is untagged and thus 0
+ if not current_selected_vlan:
+ current_selected_vlan = "0"
+
+ current_selected_vlan_range = CustomVlanRanges(current_selected_vlan)
+ used_vlans -= current_selected_vlan_range
+ current_selected_vlan_ranges = [
+ *current_selected_vlan_ranges,
+ *list(current_selected_vlan_range),
+ ]
+
+ subscription = subscriptions.get_subscription(subscription_id, model=SubscriptionTable)
+
+ # Handle both int and CustomVlanRanges
+ if isinstance(vlan, int):
+ vlan_in_use = vlan in used_vlans
+ else:
+ # For CustomVlanRanges, check if any of its values are in used_vlans
+ vlan_in_use = any(v in used_vlans for v in vlan)
+
+ if vlan_in_use:
+ port_mode = _get_port_mode(subscription)
+
+ # for tagged only; for link_member/untagged say "SP already in use"
+ if port_mode == PortMode.UNTAGGED or port_mode == PortMode.LINK_MEMBER:
+ raise ValueError("Port already in use")
+ raise ValueError(f"Vlan(s) {', '.join(map(str, sorted(used_vlans)))} already in use")
+
+ return vlan
+
+
+# TODO: rewrite to support CustomVlanRanges
+def find_allocated_vlans(
+ subscription_id: UUID | UUIDstr,
+) -> list[int]:
+ """Find all vlans already allocated to a SAP for a given port."""
+ logger.debug(
+ "Finding allocated VLANs",
+ subscription_id=subscription_id,
+ )
+
+ # Get all VLAN values used by the subscription
+ query = (
+ select(SubscriptionInstanceValueTable.value)
+ .join(
+ ResourceTypeTable,
+ SubscriptionInstanceValueTable.resource_type_id == ResourceTypeTable.resource_type_id,
+ )
+ .join(
+ SubscriptionInstanceRelationTable,
+ SubscriptionInstanceValueTable.subscription_instance_id == SubscriptionInstanceRelationTable.in_use_by_id,
+ )
+ .join(
+ SubscriptionInstanceTable,
+ SubscriptionInstanceRelationTable.depends_on_id == SubscriptionInstanceTable.subscription_instance_id,
+ )
+ .filter(
+ SubscriptionInstanceTable.subscription_id == subscription_id,
+ ResourceTypeTable.resource_type == "vlan",
+ )
+ )
+
+ used_vlan_values = db.session.execute(query).scalars().all()
+
+ if not used_vlan_values:
+ logger.debug("No VLAN values in use found")
+ return []
+ # return CustomVlanRanges([])
+
+ logger.debug("Found used VLAN values", values=used_vlan_values)
+ used_vlan_values_int = list({int(vlan) for vlan in used_vlan_values})
+ return used_vlan_values_int
+ # return CustomVlanRanges(used_vlan_values_int)
diff --git a/workflows/nsistp/terminate_nsistp.py b/workflows/nsistp/terminate_nsistp.py
new file mode 100644
index 0000000..990b29f
--- /dev/null
+++ b/workflows/nsistp/terminate_nsistp.py
@@ -0,0 +1,48 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import structlog
+from orchestrator.forms import FormPage
+from orchestrator.forms.validators import DisplaySubscription
+from orchestrator.workflow import StepList, begin, step
+from orchestrator.workflows.utils import terminate_workflow
+from pydantic_forms.types import InputForm, State, UUIDstr
+
+from products.product_types.nsistp import Nsistp
+
+logger = structlog.get_logger(__name__)
+
+
+def terminate_initial_input_form_generator(subscription_id: UUIDstr, customer_id: UUIDstr) -> InputForm:
+ temp_subscription_id = subscription_id
+
+ class TerminateNsistpForm(FormPage):
+ subscription_id: DisplaySubscription = temp_subscription_id # type: ignore
+
+ return TerminateNsistpForm
+
+
+@step("Delete subscription from OSS/BSS")
+def delete_subscription_from_oss_bss(subscription: Nsistp) -> State:
+ # TODO: add actual call to OSS/BSS to delete subscription
+
+ return {}
+
+
+@terminate_workflow("Terminate nsistp", initial_input_form=terminate_initial_input_form_generator)
+def terminate_nsistp() -> StepList:
+ return (
+ begin >> delete_subscription_from_oss_bss
+ # TODO: fill in additional steps if needed
+ )
diff --git a/workflows/nsistp/validate_nsistp.py b/workflows/nsistp/validate_nsistp.py
new file mode 100644
index 0000000..451aa5d
--- /dev/null
+++ b/workflows/nsistp/validate_nsistp.py
@@ -0,0 +1,34 @@
+# Copyright 2019-2023 SURF.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import structlog
+from orchestrator.workflow import StepList, begin, step
+from orchestrator.workflows.utils import validate_workflow
+from pydantic_forms.types import State
+
+from products.product_types.nsistp import Nsistp
+
+logger = structlog.get_logger(__name__)
+
+
+@step("Load initial state")
+def load_initial_state_nsistp(subscription: Nsistp) -> State:
+ return {
+ "subscription": subscription,
+ }
+
+
+@validate_workflow("Validate nsistp")
+def validate_nsistp() -> StepList:
+ return begin >> load_initial_state_nsistp
diff --git a/workflows/port/create_port.py b/workflows/port/create_port.py
index 5f6d13f..beb7875 100644
--- a/workflows/port/create_port.py
+++ b/workflows/port/create_port.py
@@ -12,22 +12,21 @@
# limitations under the License.
-from pydantic_forms.types import UUIDstr
-import uuid
import json
+import uuid
from random import randrange
from typing import TypeAlias, cast
from orchestrator.services.products import get_product_by_id
from orchestrator.targets import Target
from orchestrator.types import SubscriptionLifecycle
+from orchestrator.utils.json import json_dumps
from orchestrator.workflow import StepList, begin, step
from orchestrator.workflows.steps import store_process_subscription
from orchestrator.workflows.utils import create_workflow
-from orchestrator.utils.json import json_dumps
from pydantic import ConfigDict
from pydantic_forms.core import FormPage
-from pydantic_forms.types import FormGenerator, State
+from pydantic_forms.types import FormGenerator, State, UUIDstr
from pydantic_forms.validators import Choice, Label
from products.product_blocks.port import PortMode
diff --git a/workflows/shared.py b/workflows/shared.py
index 45e5e44..50381f4 100644
--- a/workflows/shared.py
+++ b/workflows/shared.py
@@ -10,8 +10,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-from pydantic_forms.types import UUIDstr
-from pydantic_forms.types import SummaryData
from pprint import pformat
from typing import Annotated, Generator, List, TypeAlias, cast
@@ -28,6 +26,7 @@
from orchestrator.types import SubscriptionLifecycle
from pydantic import ConfigDict
from pydantic_forms.core import FormPage
+from pydantic_forms.types import SummaryData, UUIDstr
from pydantic_forms.validators import Choice, MigrationSummary, migration_summary
from products.product_types.node import Node