Skip to content

Domain models

Introduction

First read the Architecture; TL;DR section of the orchestrator core documentation to get an overview of the concepts that will be covered.

Products

The Orchestrator uses the concept of a Product to describe what can be built to the end user. When a user runs a workflow to create a Product, this results in a unique instance of that product called a Subscription. A Subscription is always tied to a certain lifecycle state (eg. Initial, Provisioning, Active, Terminated, etc) and is unique per customer. In other words a Subscription contains all the information needed to uniquely identify a certain 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 additional functionality to dynamically cast variables from the database, where they are stored as a string, to their correct type in Python at runtime. Pydantic uses Python type hints to validate that the correct type is assigned. The use of typing, when used together with type checkers, already helps to make the code more robust, furthermore the use of Pydantic makes it possible to check 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.

Serialisation direct from the database

>>> some_subscription_instance_value = SubscriptionInstanceValueTable.get("ID")
>>> instance_value_from_db = some_subscription_instance_value.value
>>> instance_value_from_db
"False"
>>> if instance_value_from_db is True:
...    print("True")
... else:
...    print("False")
"True"

Serialisation using domain models

>>> class ProductBlock(ProductBlockModel):
...     instance_from_db: bool
...
>>> some_subscription_instance_value = SubscriptionInstanceValueTable.get("ID")
>>> instance_value_from_db = some_subscription_instance_value.value
>>> type(instance_value_from_db)
<class str>
>>>
>>> subscription_model = SubscriptionModel.from_subscription("ID")
>>> type(subscription_model.product_block.instance_from_db)
<class bool>
>>>
>>> subscription_model.product_block.instance_from_db
False
>>>
>>> if subscription_model.product_block.instance_from_db is True:
...    print("True")
... else:
...    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. The fixed inputs are used to differentiate between variants of the same product, for example the speed of a network port. There is always at least one top level product block that contains the resource types to administer the customer facing input. Beside resource types, the product blocks usually contain links to other 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 types

The product types in the code are upper camel cased. Per default, the product type is declared for the inactive, provisioning and active lifecycle states, and the product type name is suffixed with the state if the lifecycle is not active. Usually, the lifecycle state starts with inactive, and then transitions through provisioning to active, and finally to terminated. During its life, the subscription, an instantiation of a product for a particular customer, can transition from active to provisioning and back again many times, 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

class PortInactive(SubscriptionModel, is_base=True):
    speed: PortSpeed
    port: PortBlockInactive

class PortProvisioning(PortInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
    speed: PortSpeed
    port: PortBlockProvisioning

class Port(PortProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
    speed: PortSpeed
    port: PortBlock

As can be seen in the above example, the inactive product type definition is subclassed from SubScriptionModel, and the following definitions are subclassed from the previous one. This product has one fixed input called speed and one port product block (see below about naming). Notice that the port product block matches the lifecycle of the product, for example, the PortInactive product has a PortBlockInactive 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.

from enum import IntEnum

class PortSpeed(IntEnum):
    _1000 = 1000
    _10000 = 10000
    _40000 = 40000
    _100000 = 100000
    _400000 = 400000

This type is not only used to ensure that the speed fixed input can only take these values, but is also used in user input forms to limit the 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 here. This tool uses a configuration file to generate the boilerplate, migrations and configuration necessary to make use of the product straight away. Products need to be registered in two places. All product variants have to be added to the `SUBSCRIPTION_MODEL_REGISTRY`, in `products/__init__.py`, as shown below.
from orchestrator.domain import SUBSCRIPTION_MODEL_REGISTRY
from products.product_types.core_link import CoreLink

SUBSCRIPTION_MODEL_REGISTRY.update(
    {
        "core link 10G": CoreLink,
        "core link 100G": CoreLink,
    }
)
And all variants also have to entered into the database using a migration. The migration uses the create helper function from `orchestrator.migrations.helpers` that takes the following dictionary as an argument, see below. Notice that the name of the product and the product type need to match with the subscription model registry.
from orchestrator.migrations.helpers import create

new_products = {
    "products": {
        "core link 10G": {
            "product_id": uuid4(),
            "product_type": "CoreLink",
            "description": "Core link",
            "tag": "CORE_LINK",
            "status": "active",
            "product_blocks": [
                "CoreLink",
                "CorePort",
            ],
            "fixed_inputs": {
                "speed": CoreLinkSpeed._10000.value,
            },
        },
}

def upgrade() -> None:
    conn = op.get_bind()
    create(conn, new_products)

Product blocks

Like product types, the product blocks are declared for the inactive, provisioning and active lifecycle states. The name of the product block is suffixed with the word Block, to clearly distinguish them from the product types, and again suffixed by the state if the lifecycle is not active.

Every time a subscription is transitioned from one lifecycle to another, an automatic check is performed to ensure that resource types that are not optional are in fact present on that instantiation of the product block. This safeguards for incomplete administration for that lifecycle state.

Resource Type lifecycle. When to use None

The resource types on an inactive product block are usually all optional to allow the creation of an empty product block instance. All resource types that are used to hold the user input for the subscription is stored using resource types that are not optional anymore in the provisioning lifecycle state. All resource types used to store information that is generated while provisioning the subscription is stored using resource types that are optional while provisioning but are not optional anymore for the active lifecycle state. Resource types that are still optional in the active state are used to store non-mandatory information.

Example

class NodeBlockInactive(ProductBlockModel, product_block_name="Node"):
    type_id: int | None = None
    node_name: str | None = None
    ims_id: int | None = None
    nrm_id: int | None = None
    node_description: str | None = None

class NodeBlockProvisioning(NodeBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
    type_id: int
    node_name: str
    ims_id: int | None = None
    nrm_id: int | None = None
    node_description: str | None = None

class NodeBlock(NodeBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
    type_id: int
    node_name: str
    ims_id: int
    nrm_id: int
    node_description: str | None = None

In the simplified node product block shown above, the type and the name of the node are supplied by the user and stored on the NodeBlockInactive. Then, the subscription transitions to Provisioning and a check is performed to ensure that both pieces of information are present on the product block. During the provisioning phase the node is administered in IMS and the handle to that information is stored on the NodeBlockProvsioning. Next, the node is provisioned in the NRM and the handle is also stored. If both of these two actions were successful, the subscription is transitioned to Active and it is checked that the type and node name, and the IMS and NRM ID, are present on the product block. The description of the node remains optional, even in the active state. These checks ensure that information that is necessary for a particular state is present so that the actions that are performed in that state do not fail.

Product Block customisation

Sometimes there are resource types that depend on information stored on other product blocks, even on linked product blocks that do not belong to the same subscription. This kind of types need to be calculated at run time so that they include the most recent information. Consider the following example of a, stripped down version, of a port and node product block, and a title for the port block that is generated dynamically.

class NodeBlock(NodeBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
    node_name: str

class PortBlock(PortBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
    port_name: str
    node: NodeBlock

    @serializable_property
    def title(self) -> str:
        return f"{self.port_name} on {self.node.node_name}"

class Port(PortProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
    port: PortBlock

A @serializable_property has been added that will dynamically render the title of the port product block. Even after a modify workflow was run to change the node name on the node subscription, the title of the port block will always be up to date. The title can be referenced as any other resource type using subscription.port.title. This is not a random example, the title of a product block is used by the orchestrator GUI while displaying detailed subscription information.