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"
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,
}
)
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.