Skip to content
Snippets Groups Projects
Commit d3ea4aff authored by Hakan Calim's avatar Hakan Calim
Browse files

NAT-286: meged develop

parents 2cf2bbd9 8701ab80
Branches
Tags
1 merge request!89Feature/nat 286 create unit tests for netbox client
Pipeline #84248 failed
Showing
with 361 additions and 258 deletions
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
The GÉANT interpretation of [`orchestrator-core`](https://github.com/workfloworchestrator/orchestrator-core). The GÉANT interpretation of [`orchestrator-core`](https://github.com/workfloworchestrator/orchestrator-core).
## Documentation ## Documentation
You can build the documentation locally using either [build-docs.sh](build-docs.sh) or [test-docs.sh](test-docs.sh). You can build the documentation locally using [build-docs.sh](build-docs.sh).
...@@ -3,6 +3,9 @@ Glossary of terms ...@@ -3,6 +3,9 @@ Glossary of terms
.. glossary:: .. glossary::
API
Application Programming Interface
BGP BGP
Border Gateway Protocol: a path vector routing protocol described in Border Gateway Protocol: a path vector routing protocol described in
`RFC 4271 <https://datatracker.ietf.org/doc/html/rfc4271>`_. `RFC 4271 <https://datatracker.ietf.org/doc/html/rfc4271>`_.
......
``gso.products`` ``gso.products``
================ ================
.. automodule:: gso.products
:members:
:show-inheritance:
Subpackages Subpackages
----------- -----------
......
import ipaddress
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from orchestrator.security import opa_security_default from orchestrator.security import opa_security_default
from orchestrator.services import processes, subscriptions from orchestrator.services import processes
from orchestrator.services import subscriptions as wfo_subscriptions
from pydantic import BaseModel, root_validator, validator
from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.exc import MultipleResultsFound
from gso.schemas.imports import ImportResponseModel, IptrunkImportModel, RouterImportModel, SiteImportModel from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity
from gso.products.product_blocks.router import RouterRole, RouterVendor
from gso.products.product_blocks.site import SiteTier
from gso.services import subscriptions
from gso.services.crm import CustomerNotFoundError, get_customer_by_name
from gso.utils.helpers import LAGMember
router = APIRouter(prefix="/imports", tags=["Imports"], dependencies=[Depends(opa_security_default)]) router = APIRouter(prefix="/imports", tags=["Imports"], dependencies=[Depends(opa_security_default)])
class ImportResponseModel(BaseModel):
pid: UUID
detail: str
class SiteImportModel(BaseModel):
site_name: str
site_city: str
site_country: str
site_country_code: str
site_latitude: float
site_longitude: float
site_bgp_community_id: int
site_internal_id: int
site_tier: SiteTier
site_ts_address: str
customer: str
class RouterImportModel(BaseModel):
customer: str
router_site: str
hostname: str
ts_port: int
router_vendor: RouterVendor
router_role: RouterRole
is_ias_connected: bool
router_lo_ipv4_address: ipaddress.IPv4Address
router_lo_ipv6_address: ipaddress.IPv6Address
router_lo_iso_address: str
router_si_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None
class IptrunkImportModel(BaseModel):
customer: str
geant_s_sid: str
iptrunk_type: IptrunkType
iptrunk_description: str
iptrunk_speed: PhyPortCapacity
iptrunk_minimum_links: int
side_a_node_id: str
side_a_ae_iface: str
side_a_ae_geant_a_sid: str
side_a_ae_members: list[LAGMember]
side_b_node_id: str
side_b_ae_iface: str
side_b_ae_geant_a_sid: str
side_b_ae_members: list[LAGMember]
iptrunk_ipv4_network: ipaddress.IPv4Network
iptrunk_ipv6_network: ipaddress.IPv6Network
@classmethod
def _get_active_routers(cls) -> set[str]:
return {
str(router_id) for router_id in subscriptions.get_active_router_subscriptions(fields=["subscription_id"])
}
@validator("customer")
def check_if_customer_exists(cls, value: str) -> str:
try:
get_customer_by_name(value)
except CustomerNotFoundError:
raise ValueError(f"Customer {value} not found")
return value
@validator("side_a_node_id", "side_b_node_id")
def check_if_router_side_is_available(cls, value: str) -> str:
if value not in cls._get_active_routers():
raise ValueError(f"Router {value} not found")
return value
@validator("side_a_ae_members", "side_b_ae_members")
def check_side_uniqueness(cls, value: list[str]) -> list[str]:
if len(value) != len(set(value)):
raise ValueError("Items must be unique")
return value
@root_validator
def check_members(cls, values: dict[str, Any]) -> dict[str, Any]:
min_links = values["iptrunk_minimum_links"]
side_a_members = values.get("side_a_ae_members", [])
side_b_members = values.get("side_b_ae_members", [])
len_a = len(side_a_members)
len_b = len(side_b_members)
if len_a < min_links:
raise ValueError(f"Side A members should be at least {min_links} (iptrunk_minimum_links)")
if len_a != len_b:
raise ValueError("Mismatch between Side A and B members")
return values
def _start_process(process_name: str, data: dict) -> UUID: def _start_process(process_name: str, data: dict) -> UUID:
"""Start a process and handle common exceptions.""" """Start a process and handle common exceptions."""
...@@ -42,7 +151,7 @@ def import_site(site: SiteImportModel) -> dict[str, Any]: ...@@ -42,7 +151,7 @@ def import_site(site: SiteImportModel) -> dict[str, Any]:
:raises HTTPException: If the site already exists or if there's an error in the process. :raises HTTPException: If the site already exists or if there's an error in the process.
""" """
try: try:
subscription = subscriptions.retrieve_subscription_by_subscription_instance_value( subscription = wfo_subscriptions.retrieve_subscription_by_subscription_instance_value(
resource_type="site_name", value=site.site_name, sub_status=("provisioning", "active") resource_type="site_name", value=site.site_name, sub_status=("provisioning", "active")
) )
if subscription: if subscription:
......
import logging import logging
import os import os
from alembic import context
from sqlalchemy import engine_from_config, pool
import orchestrator import orchestrator
from alembic import context
from orchestrator.db.database import BaseModel from orchestrator.db.database import BaseModel
from orchestrator.settings import app_settings from orchestrator.settings import app_settings
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
...@@ -61,7 +61,7 @@ def run_migrations_online() -> None: ...@@ -61,7 +61,7 @@ def run_migrations_online() -> None:
# this callback is used to prevent an auto-migration from being generated # this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema # when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives): # type: ignore def process_revision_directives(context, revision, directives): # type: ignore[no-untyped-def]
if getattr(config.cmd_opts, "autogenerate", False): if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0] script = directives[0]
if script.upgrade_ops.is_empty(): if script.upgrade_ops.is_empty():
......
"""Modify IP trunk model.
Revision ID: 394dc60d5c02
Revises: 01e42c100448
Create Date: 2023-10-11 17:55:38.289125
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '394dc60d5c02'
down_revision = '01e42c100448'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkSideBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_side_ae_members_description'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkSideBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_side_ae_members_description'))
"""))
conn.execute(sa.text("""
DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkSideBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_side_ae_members'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkSideBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_side_ae_members'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instance_values WHERE subscription_instance_values.resource_type_id IN (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_side_ae_members_description', 'iptrunk_side_ae_members'))
"""))
conn.execute(sa.text("""
DELETE FROM resource_types WHERE resource_types.resource_type IN ('iptrunk_side_ae_members_description', 'iptrunk_side_ae_members')
"""))
conn.execute(sa.text("""
INSERT INTO product_blocks (name, description, tag, status) VALUES ('IptrunkInterfaceBlock', 'Interface in a LAG as part of an IP trunk', 'IPTINT', 'active') RETURNING product_blocks.product_block_id
"""))
conn.execute(sa.text("""
INSERT INTO resource_types (resource_type, description) VALUES ('interface_description', 'Description of a LAG interface') RETURNING resource_types.resource_type_id
"""))
conn.execute(sa.text("""
INSERT INTO resource_types (resource_type, description) VALUES ('interface_name', 'Interface name of a LAG member') RETURNING resource_types.resource_type_id
"""))
conn.execute(sa.text("""
INSERT INTO product_block_relations (in_use_by_id, depends_on_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkSideBlock')), (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock')))
"""))
conn.execute(sa.text("""
INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_description')))
"""))
conn.execute(sa.text("""
INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_name')))
"""))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_description'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_description'))
"""))
conn.execute(sa.text("""
DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_name'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_name'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instance_values WHERE subscription_instance_values.resource_type_id IN (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('interface_description', 'interface_name'))
"""))
conn.execute(sa.text("""
DELETE FROM resource_types WHERE resource_types.resource_type IN ('interface_description', 'interface_name')
"""))
conn.execute(sa.text("""
DELETE FROM product_block_relations WHERE product_block_relations.in_use_by_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkSideBlock')) AND product_block_relations.depends_on_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock'))
"""))
conn.execute(sa.text("""
DELETE FROM subscription_instances WHERE subscription_instances.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock'))
"""))
conn.execute(sa.text("""
DELETE FROM product_blocks WHERE product_blocks.name IN ('IptrunkInterfaceBlock')
"""))
...@@ -48,7 +48,6 @@ ...@@ -48,7 +48,6 @@
"PROVISIONING_PROXY": { "PROVISIONING_PROXY": {
"scheme": "https", "scheme": "https",
"api_base": "localhost:44444", "api_base": "localhost:44444",
"auth": "Bearer <token>",
"api_version": 1123 "api_version": 1123
} }
} }
"""Module that updates the domain model of :term:`GSO`. Should contain all types of subscriptions.""" """Module that updates the domain model of :term:`GSO`. Should contain all types of subscriptions.
.. warning::
Whenever a new product type is added, this should be reflected in the :py:class:`gso.products.ProductType`
enumerator.
"""
from orchestrator.domain import SUBSCRIPTION_MODEL_REGISTRY from orchestrator.domain import SUBSCRIPTION_MODEL_REGISTRY
from pydantic_forms.types import strEnum
from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.iptrunk import Iptrunk
from gso.products.product_types.router import Router from gso.products.product_types.router import Router
from gso.products.product_types.site import Site from gso.products.product_types.site import Site
class ProductType(strEnum):
SITE = "Site"
ROUTER = "Router"
IP_TRUNK = "IP trunk"
SUBSCRIPTION_MODEL_REGISTRY.update( SUBSCRIPTION_MODEL_REGISTRY.update(
{ {
"Site": Site, "Site": Site,
...@@ -12,5 +25,3 @@ SUBSCRIPTION_MODEL_REGISTRY.update( ...@@ -12,5 +25,3 @@ SUBSCRIPTION_MODEL_REGISTRY.update(
"IP trunk": Iptrunk, "IP trunk": Iptrunk,
} }
) )
__all__ = ["Site", "Iptrunk", "Router"]
"""Product blocks that store information about subscriptions. """Product blocks that store information about subscriptions."""
In this file, some enumerators may be declared that are available for use across all subscriptions.
"""
from orchestrator.types import strEnum
class PhyPortCapacity(strEnum):
"""Physical port capacity enumerator.
An enumerator that has the different possible capacities of ports that are available to use in subscriptions.
"""
ONE_GIGABIT_PER_SECOND = "1G"
TEN_GIGABIT_PER_SECOND = "10G"
HUNDRED_GIGABIT_PER_SECOND = "100G"
FOUR_HUNDRED_GIGABIT_PER_SECOND = "400G"
...@@ -6,11 +6,22 @@ from typing import TypeVar ...@@ -6,11 +6,22 @@ from typing import TypeVar
from orchestrator.domain.base import ProductBlockModel from orchestrator.domain.base import ProductBlockModel
from orchestrator.forms.validators import UniqueConstrainedList from orchestrator.forms.validators import UniqueConstrainedList
from orchestrator.types import SubscriptionLifecycle, strEnum from orchestrator.types import SubscriptionLifecycle, strEnum
from pydantic import Field
from gso.products.product_blocks.router import RouterBlock, RouterBlockInactive, RouterBlockProvisioning from gso.products.product_blocks.router import RouterBlock, RouterBlockInactive, RouterBlockProvisioning
class PhyPortCapacity(strEnum):
"""Physical port capacity enumerator.
An enumerator that has the different possible capacities of ports that are available to use in subscriptions.
"""
ONE_GIGABIT_PER_SECOND = "1G"
TEN_GIGABIT_PER_SECOND = "10G"
HUNDRED_GIGABIT_PER_SECOND = "100G"
FOUR_HUNDRED_GIGABIT_PER_SECOND = "400G"
class IptrunkType(strEnum): class IptrunkType(strEnum):
DARK_FIBER = "Dark_fiber" DARK_FIBER = "Dark_fiber"
LEASED = "Leased" LEASED = "Leased"
...@@ -19,6 +30,28 @@ class IptrunkType(strEnum): ...@@ -19,6 +30,28 @@ class IptrunkType(strEnum):
T = TypeVar("T", covariant=True) T = TypeVar("T", covariant=True)
class LAGMemberList(UniqueConstrainedList[T]): # type: ignore[type-var]
pass
class IptrunkInterfaceBlockInactive(
ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="IptrunkInterfaceBlock"
):
# TODO: add validation for interface names, making the type a constrained string
interface_name: str | None = None
interface_description: str | None = None
class IptrunkInterfaceBlockProvisioning(IptrunkInterfaceBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
interface_name: str
interface_description: str
class IptrunkInterfaceBlock(IptrunkInterfaceBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
interface_name: str
interface_description: str
class IptrunkSides(UniqueConstrainedList[T]): # type: ignore[type-var] class IptrunkSides(UniqueConstrainedList[T]): # type: ignore[type-var]
min_items = 2 min_items = 2
max_items = 2 max_items = 2
...@@ -30,24 +63,21 @@ class IptrunkSideBlockInactive( ...@@ -30,24 +63,21 @@ class IptrunkSideBlockInactive(
iptrunk_side_node: RouterBlockInactive iptrunk_side_node: RouterBlockInactive
iptrunk_side_ae_iface: str | None = None iptrunk_side_ae_iface: str | None = None
iptrunk_side_ae_geant_a_sid: str | None = None iptrunk_side_ae_geant_a_sid: str | None = None
iptrunk_side_ae_members: list[str] = Field(default_factory=list) iptrunk_side_ae_members: LAGMemberList[IptrunkInterfaceBlockInactive]
iptrunk_side_ae_members_description: list[str] = Field(default_factory=list)
class IptrunkSideBlockProvisioning(IptrunkSideBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): class IptrunkSideBlockProvisioning(IptrunkSideBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
iptrunk_side_node: RouterBlockProvisioning iptrunk_side_node: RouterBlockProvisioning
iptrunk_side_ae_iface: str | None = None iptrunk_side_ae_iface: str | None = None
iptrunk_side_ae_geant_a_sid: str | None = None iptrunk_side_ae_geant_a_sid: str | None = None
iptrunk_side_ae_members: list[str] = Field(default_factory=list) iptrunk_side_ae_members: LAGMemberList[IptrunkInterfaceBlockProvisioning]
iptrunk_side_ae_members_description: list[str] = Field(default_factory=list)
class IptrunkSideBlock(IptrunkSideBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): class IptrunkSideBlock(IptrunkSideBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
iptrunk_side_node: RouterBlock iptrunk_side_node: RouterBlock
iptrunk_side_ae_iface: str | None = None iptrunk_side_ae_iface: str | None = None
iptrunk_side_ae_geant_a_sid: str | None = None iptrunk_side_ae_geant_a_sid: str | None = None
iptrunk_side_ae_members: list[str] = Field(default_factory=list) iptrunk_side_ae_members: LAGMemberList[IptrunkInterfaceBlock]
iptrunk_side_ae_members_description: list[str] = Field(default_factory=list)
class IptrunkBlockInactive( class IptrunkBlockInactive(
...@@ -58,12 +88,11 @@ class IptrunkBlockInactive( ...@@ -58,12 +88,11 @@ class IptrunkBlockInactive(
geant_s_sid: str | None = None geant_s_sid: str | None = None
iptrunk_description: str | None = None iptrunk_description: str | None = None
iptrunk_type: IptrunkType | None = None iptrunk_type: IptrunkType | None = None
iptrunk_speed: str | None = None iptrunk_speed: PhyPortCapacity | None = None
iptrunk_minimum_links: int | None = None iptrunk_minimum_links: int | None = None
iptrunk_isis_metric: int | None = None iptrunk_isis_metric: int | None = None
iptrunk_ipv4_network: ipaddress.IPv4Network | None = None iptrunk_ipv4_network: ipaddress.IPv4Network | None = None
iptrunk_ipv6_network: ipaddress.IPv6Network | None = None iptrunk_ipv6_network: ipaddress.IPv6Network | None = None
#
iptrunk_sides: IptrunkSides[IptrunkSideBlockInactive] iptrunk_sides: IptrunkSides[IptrunkSideBlockInactive]
...@@ -73,12 +102,11 @@ class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLife ...@@ -73,12 +102,11 @@ class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLife
geant_s_sid: str | None = None geant_s_sid: str | None = None
iptrunk_description: str | None = None iptrunk_description: str | None = None
iptrunk_type: IptrunkType | None = None iptrunk_type: IptrunkType | None = None
iptrunk_speed: str | None = None iptrunk_speed: PhyPortCapacity | None = None
iptrunk_minimum_links: int | None = None iptrunk_minimum_links: int | None = None
iptrunk_isis_metric: int | None = None iptrunk_isis_metric: int | None = None
iptrunk_ipv4_network: ipaddress.IPv4Network | None = None iptrunk_ipv4_network: ipaddress.IPv4Network | None = None
iptrunk_ipv6_network: ipaddress.IPv6Network | None = None iptrunk_ipv6_network: ipaddress.IPv6Network | None = None
#
iptrunk_sides: IptrunkSides[IptrunkSideBlockProvisioning] iptrunk_sides: IptrunkSides[IptrunkSideBlockProvisioning]
...@@ -92,7 +120,7 @@ class IptrunkBlock(IptrunkBlockProvisioning, lifecycle=[SubscriptionLifecycle.AC ...@@ -92,7 +120,7 @@ class IptrunkBlock(IptrunkBlockProvisioning, lifecycle=[SubscriptionLifecycle.AC
#: The type of trunk, can be either dark fibre or leased capacity. #: The type of trunk, can be either dark fibre or leased capacity.
iptrunk_type: IptrunkType iptrunk_type: IptrunkType
#: The speed of the trunk, measured per interface associated with it. #: The speed of the trunk, measured per interface associated with it.
iptrunk_speed: str # FIXME: should be of PhyPortCapacity type iptrunk_speed: PhyPortCapacity
#: The minimum amount of links the trunk should consist of. #: The minimum amount of links the trunk should consist of.
iptrunk_minimum_links: int iptrunk_minimum_links: int
#: The :term:`IS-IS` metric of this link #: The :term:`IS-IS` metric of this link
......
...@@ -3,9 +3,9 @@ import ipaddress ...@@ -3,9 +3,9 @@ import ipaddress
from orchestrator.domain.base import ProductBlockModel from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle, strEnum from orchestrator.types import SubscriptionLifecycle, strEnum
from pydantic import ConstrainedInt
from gso.products.product_blocks.site import SiteBlock, SiteBlockInactive, SiteBlockProvisioning from gso.products.product_blocks.site import SiteBlock, SiteBlockInactive, SiteBlockProvisioning
from gso.products.shared import PortNumber
class RouterVendor(strEnum): class RouterVendor(strEnum):
...@@ -23,6 +23,16 @@ class RouterRole(strEnum): ...@@ -23,6 +23,16 @@ class RouterRole(strEnum):
AMT = "amt" AMT = "amt"
class PortNumber(ConstrainedInt):
"""Constrained integer for valid port numbers.
The range from 49152 to 65535 is marked as ephemeral, and can therefore not be selected for permanent allocation.
"""
gt = 0
le = 49151
class RouterBlockInactive( class RouterBlockInactive(
ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="RouterBlock" ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="RouterBlock"
): ):
...@@ -92,5 +102,5 @@ class RouterBlock(RouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTI ...@@ -92,5 +102,5 @@ class RouterBlock(RouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTI
router_role: RouterRole router_role: RouterRole
#: The :class:`Site` that this router resides in. Both physically and computationally. #: The :class:`Site` that this router resides in. Both physically and computationally.
router_site: SiteBlock router_site: SiteBlock
#: The router is going to have a LT interface between inet0 and IAS #: The router is going to have an LT interface between inet0 and IAS
router_is_ias_connected: bool router_is_ias_connected: bool
"""The product block that describes a site subscription.""" """The product block that describes a site subscription."""
import re
from typing import Union
from orchestrator.domain.base import ProductBlockModel from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle, strEnum from orchestrator.types import SubscriptionLifecycle, strEnum
from pydantic import ConstrainedStr
from gso.schemas.types import LatitudeCoordinate, LongitudeCoordinate
class SiteTier(strEnum): class SiteTier(strEnum):
...@@ -18,6 +20,42 @@ class SiteTier(strEnum): ...@@ -18,6 +20,42 @@ class SiteTier(strEnum):
TIER4 = 4 TIER4 = 4
class LatitudeCoordinate(ConstrainedStr):
"""A latitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. It can be a
floating-point number or an integer.
Valid examples: 40.7128, -74.0060, 90, -90, 0
"""
regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'.")
return value
class LongitudeCoordinate(ConstrainedStr):
"""A longitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the longitude
range of -180 to +180 degrees. It can be a floating point number or an integer.
Valid examples: 40.7128, -74.0060, 180, -180, 0
"""
regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180'")
return value
class SiteBlockInactive(ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="SiteBlock"): class SiteBlockInactive(ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="SiteBlock"):
"""A site that's currently inactive, see :class:`SiteBlock`.""" """A site that's currently inactive, see :class:`SiteBlock`."""
......
from pydantic import ConstrainedInt
class PortNumber(ConstrainedInt):
"""Constrained integer for valid port numbers.
The range from 49152 to 65535 is marked as ephemeral, and can therefore not be selected for permanent allocation.
"""
gt = 0
le = 49151
from orchestrator.types import strEnum
class ProductType(strEnum):
SITE = "Site"
ROUTER = "Router"
IP_TRUNK = "IP trunk"
class SubscriptionStatus(strEnum):
ACTIVE = "active"
import ipaddress
from typing import Any
from uuid import UUID
from pydantic import BaseModel, root_validator, validator
from gso.products.product_blocks import PhyPortCapacity
from gso.products.product_blocks.iptrunk import IptrunkType
from gso.products.product_blocks.router import RouterRole, RouterVendor
from gso.products.product_blocks.site import SiteTier
from gso.services import subscriptions
from gso.services.crm import CustomerNotFoundError, get_customer_by_name
class ImportResponseModel(BaseModel):
pid: UUID
detail: str
class SiteImportModel(BaseModel):
site_name: str
site_city: str
site_country: str
site_country_code: str
site_latitude: float
site_longitude: float
site_bgp_community_id: int
site_internal_id: int
site_tier: SiteTier
site_ts_address: str
customer: str
class RouterImportModel(BaseModel):
customer: str
router_site: str
hostname: str
ts_port: int
router_vendor: RouterVendor
router_role: RouterRole
is_ias_connected: bool
router_lo_ipv4_address: ipaddress.IPv4Address
router_lo_ipv6_address: ipaddress.IPv6Address
router_lo_iso_address: str
router_si_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None
router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None
class IptrunkImportModel(BaseModel):
customer: str
geant_s_sid: str
iptrunk_type: IptrunkType
iptrunk_description: str
iptrunk_speed: PhyPortCapacity
iptrunk_minimum_links: int
iptrunk_sideA_node_id: str
iptrunk_sideA_ae_iface: str
iptrunk_sideA_ae_geant_a_sid: str
iptrunk_sideA_ae_members: list[str]
iptrunk_sideA_ae_members_descriptions: list[str]
iptrunk_sideB_node_id: str
iptrunk_sideB_ae_iface: str
iptrunk_sideB_ae_geant_a_sid: str
iptrunk_sideB_ae_members: list[str]
iptrunk_sideB_ae_members_descriptions: list[str]
iptrunk_ipv4_network: ipaddress.IPv4Network
iptrunk_ipv6_network: ipaddress.IPv6Network
@classmethod
def _get_active_routers(cls) -> set[str]:
return {
str(router_id) for router_id in subscriptions.get_active_router_subscriptions(fields=["subscription_id"])
}
@validator("customer")
def check_if_customer_exists(cls, value: str) -> str:
try:
get_customer_by_name(value)
except CustomerNotFoundError:
raise ValueError(f"Customer {value} not found")
return value
@validator("iptrunk_sideA_node_id", "iptrunk_sideB_node_id")
def check_if_router_side_is_available(cls, value: str) -> str:
if value not in cls._get_active_routers():
raise ValueError("Router not found")
return value
@validator("iptrunk_sideA_ae_members", "iptrunk_sideB_ae_members")
def check_side_uniqueness(cls, value: list[str]) -> list[str]:
if len(value) != len(set(value)):
raise ValueError("Items must be unique")
return value
@root_validator
def check_members(cls, values: dict[str, Any]) -> dict[str, Any]:
min_links = values["iptrunk_minimum_links"]
side_a_members = values.get("iptrunk_sideA_ae_members", [])
side_a_descriptions = values.get("iptrunk_sideA_ae_members_descriptions", [])
side_b_members = values.get("iptrunk_sideB_ae_members", [])
side_b_descriptions = values.get("iptrunk_sideB_ae_members_descriptions", [])
len_a = len(side_a_members)
len_a_desc = len(side_a_descriptions)
len_b = len(side_b_members)
len_b_desc = len(side_b_descriptions)
if len_a < min_links:
raise ValueError(f"Side A members should be at least {min_links} (iptrunk_minimum_links)")
if len_a != len_a_desc:
raise ValueError("Mismatch in Side A members and their descriptions")
if len_a != len_b:
raise ValueError("Mismatch between Side A and B members")
if len_a != len_b_desc:
raise ValueError("Mismatch in Side B members and their descriptions")
return values
import re
from typing import Union
from pydantic import ConstrainedStr
class LatitudeCoordinate(ConstrainedStr):
"""A latitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. It can be a
floating-point number or an integer.
Valid examples: 40.7128, -74.0060, 90, -90, 0
"""
regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'.")
return value
class LongitudeCoordinate(ConstrainedStr):
"""A longitude coordinate, modeled as a constrained string.
The coordinate must match the format conforming to the longitude
range of -180 to +180 degrees. It can be a floating point number or an integer.
Valid examples: 40.7128, -74.0060, 180, -180, 0
"""
regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$")
@classmethod
def validate(cls, value: Union[str]) -> Union[str]:
if not cls.regex.match(value):
raise ValueError("Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180'")
return value
from typing import Any from typing import Any
from pydantic_forms.validators import Choice
class CustomerNotFoundError(Exception): class CustomerNotFoundError(Exception):
"""Exception raised when a customer is not found.""" """Exception raised when a customer is not found."""
...@@ -22,3 +24,11 @@ def get_customer_by_name(name: str) -> dict[str, Any]: ...@@ -22,3 +24,11 @@ def get_customer_by_name(name: str) -> dict[str, Any]:
return customer return customer
raise CustomerNotFoundError(f"Customer {name} not found") raise CustomerNotFoundError(f"Customer {name} not found")
def customer_selector() -> Choice:
customers = {}
for customer in all_customers():
customers[customer["id"]] = customer["name"]
return Choice("Select a customer", zip(customers.keys(), customers.items())) # type: ignore[arg-type]
...@@ -5,13 +5,12 @@ import pydantic ...@@ -5,13 +5,12 @@ import pydantic
import pynetbox import pynetbox
from pynetbox.models.dcim import Devices, DeviceTypes, Interfaces from pynetbox.models.dcim import Devices, DeviceTypes, Interfaces
from gso.products import Router from gso.products.product_types.router import Router
from gso.settings import load_oss_params from gso.settings import load_oss_params
from gso.utils.device_info import DEFAULT_SITE, FEASIBLE_IP_TRUNK_LAG_RANGE, ROUTER_ROLE, TierInfo from gso.utils.device_info import DEFAULT_SITE, FEASIBLE_IP_TRUNK_LAG_RANGE, ROUTER_ROLE, TierInfo
from gso.utils.exceptions import NotFoundError, WorkflowStateError from gso.utils.exceptions import NotFoundError, WorkflowStateError
# Define device models
class Manufacturer(pydantic.BaseModel): class Manufacturer(pydantic.BaseModel):
"""Defines the manufacturer of a device.""" """Defines the manufacturer of a device."""
...@@ -22,8 +21,7 @@ class Manufacturer(pydantic.BaseModel): ...@@ -22,8 +21,7 @@ class Manufacturer(pydantic.BaseModel):
class DeviceType(pydantic.BaseModel): class DeviceType(pydantic.BaseModel):
"""Defines the device type. """Defines the device type.
The manufacturer should be created first to get the manufacturer id, The manufacturer should be created first to get the manufacturer id, which is defined here as int.
which is defined here as int.
""" """
manufacturer: int manufacturer: int
...@@ -46,7 +44,7 @@ class Site(pydantic.BaseModel): ...@@ -46,7 +44,7 @@ class Site(pydantic.BaseModel):
class NetboxClient: class NetboxClient:
"""Implement all methods to communicate with the NetBox API.""" """Implement all methods to communicate with the Netbox :term:`API`."""
def __init__(self) -> None: def __init__(self) -> None:
self.netbox_params = load_oss_params().NETBOX self.netbox_params = load_oss_params().NETBOX
...@@ -56,7 +54,7 @@ class NetboxClient: ...@@ -56,7 +54,7 @@ class NetboxClient:
return list(self.netbox.dcim.devices.all()) return list(self.netbox.dcim.devices.all())
def get_device_by_name(self, device_name: str) -> Devices: def get_device_by_name(self, device_name: str) -> Devices:
"""Return the device object by name from netbox, or None if not found.""" """Return the device object by name from netbox, or ``None`` if not found."""
return self.netbox.dcim.devices.get(name=device_name) return self.netbox.dcim.devices.get(name=device_name)
def get_interfaces_by_device(self, device_name: str, speed: str) -> list[Interfaces]: def get_interfaces_by_device(self, device_name: str, speed: str) -> list[Interfaces]:
...@@ -87,11 +85,13 @@ class NetboxClient: ...@@ -87,11 +85,13 @@ class NetboxClient:
) )
def create_device_type(self, manufacturer: str, model: str, slug: str) -> DeviceTypes: def create_device_type(self, manufacturer: str, model: str, slug: str) -> DeviceTypes:
"""Create a new device type in netbox.""" """Create a new device type in Netbox."""
# First get manufacturer id # First get manufacturer id
manufacturer_id = int(self.netbox.dcim.manufacturers.get(name=manufacturer).id) manufacturer_id = int(self.netbox.dcim.manufacturers.get(name=manufacturer).id)
device_type = DeviceType(**{"manufacturer": manufacturer_id, "model": model, "slug": slug}) # type: ignore device_type = DeviceType(
**{"manufacturer": manufacturer_id, "model": model, "slug": slug} # type: ignore[arg-type]
)
return self.netbox.dcim.device_types.create(dict(device_type)) return self.netbox.dcim.device_types.create(dict(device_type))
def create_device_role(self, name: str, slug: str) -> DeviceRole: def create_device_role(self, name: str, slug: str) -> DeviceRole:
...@@ -116,7 +116,7 @@ class NetboxClient: ...@@ -116,7 +116,7 @@ class NetboxClient:
return None return None
def create_device(self, router_name: str, site_tier: str) -> Devices: def create_device(self, router_name: str, site_tier: str) -> Devices:
"""Create a new device in netbox.""" """Create a new device in Netbox."""
# Get device type id # Get device type id
tier_info = TierInfo().get_module_by_name(f"Tier{site_tier}") tier_info = TierInfo().get_module_by_name(f"Tier{site_tier}")
...@@ -159,7 +159,7 @@ class NetboxClient: ...@@ -159,7 +159,7 @@ class NetboxClient:
def attach_interface_to_lag( def attach_interface_to_lag(
self, device_name: str, lag_name: str, iface_name: str, description: str | None = None self, device_name: str, lag_name: str, iface_name: str, description: str | None = None
) -> Interfaces: ) -> Interfaces:
"""Assign a given interface to a LAG. """Assign a given interface to a :term:`LAG`.
Returns the interface object after assignment. Returns the interface object after assignment.
""" """
...@@ -225,20 +225,20 @@ class NetboxClient: ...@@ -225,20 +225,20 @@ class NetboxClient:
return interface return interface
def get_available_lags(self, router_id: UUID) -> list[str]: def get_available_lags(self, router_id: UUID) -> list[str]:
"""Return all available lags not assigned to a device.""" """Return all available :term:`LAG`s not assigned to a device."""
router_name = Router.from_subscription(router_id).router.router_fqdn router_name = Router.from_subscription(router_id).router.router_fqdn
device = self.get_device_by_name(router_name) device = self.get_device_by_name(router_name)
# Get the existing lag interfaces for the device # Get the existing LAG interfaces for the device
lag_interface_names = [ lag_interface_names = [
interface["name"] for interface in self.netbox.dcim.interfaces.filter(device=device.name, type="lag") interface["name"] for interface in self.netbox.dcim.interfaces.filter(device=device.name, type="lag")
] ]
# Generate all feasible lags # Generate all feasible LAGs
all_feasible_lags = [f"LAG-{i}" for i in FEASIBLE_IP_TRUNK_LAG_RANGE] all_feasible_lags = [f"LAG-{i}" for i in FEASIBLE_IP_TRUNK_LAG_RANGE]
# Return available lags not assigned to the device # Return available LAGs not assigned to the device
return [lag for lag in all_feasible_lags if lag not in lag_interface_names] return [lag for lag in all_feasible_lags if lag not in lag_interface_names]
@staticmethod @staticmethod
......
...@@ -8,14 +8,12 @@ from orchestrator.db import ( ...@@ -8,14 +8,12 @@ from orchestrator.db import (
SubscriptionInstanceValueTable, SubscriptionInstanceValueTable,
SubscriptionTable, SubscriptionTable,
) )
from orchestrator.types import SubscriptionLifecycle
from gso.schemas.enums import ProductType, SubscriptionStatus from gso.products import ProductType
def get_active_subscriptions( def get_active_subscriptions(product_type: str, fields: list[str]) -> list[Subscription]:
product_type: str,
fields: list[str],
) -> list[Subscription]:
"""Retrieve active subscriptions for a specific product type. """Retrieve active subscriptions for a specific product type.
:param product_type: The type of the product for which to retrieve subscriptions. :param product_type: The type of the product for which to retrieve subscriptions.
...@@ -32,7 +30,7 @@ def get_active_subscriptions( ...@@ -32,7 +30,7 @@ def get_active_subscriptions(
SubscriptionTable.query.join(ProductTable) SubscriptionTable.query.join(ProductTable)
.filter( .filter(
ProductTable.product_type == product_type, ProductTable.product_type == product_type,
SubscriptionTable.status == SubscriptionStatus.ACTIVE, SubscriptionTable.status == SubscriptionLifecycle.ACTIVE,
) )
.with_entities(*dynamic_fields) .with_entities(*dynamic_fields)
.all() .all()
...@@ -91,6 +89,6 @@ def get_active_site_subscription_by_name(site_name: str) -> Subscription: ...@@ -91,6 +89,6 @@ def get_active_site_subscription_by_name(site_name: str) -> Subscription:
.join(ResourceTypeTable) .join(ResourceTypeTable)
.filter(SubscriptionInstanceValueTable.value == site_name) .filter(SubscriptionInstanceValueTable.value == site_name)
.filter(ResourceTypeTable.resource_type == "site_name") .filter(ResourceTypeTable.resource_type == "site_name")
.filter(SubscriptionTable.status == SubscriptionStatus.ACTIVE) .filter(SubscriptionTable.status == SubscriptionLifecycle.ACTIVE)
.first() .first()
) )
""":term:`GSO` settings. """:term:`GSO` settings.
Ensuring that the required parameters are set correctly. Ensuring that the required parameters are set correctly. An example file ``oss-params-example.json`` is present in the
:term:`GSO` package itself.
""" """
import ipaddress import ipaddress
...@@ -8,7 +9,7 @@ import json ...@@ -8,7 +9,7 @@ import json
import logging import logging
import os import os
from pydantic import BaseSettings from pydantic import BaseSettings, NonNegativeInt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -31,12 +32,20 @@ class InfoBloxParams(BaseSettings): ...@@ -31,12 +32,20 @@ class InfoBloxParams(BaseSettings):
password: str password: str
class V4Netmask(NonNegativeInt):
le = 32
class V6Netmask(NonNegativeInt):
le = 128
class V4NetworkParams(BaseSettings): class V4NetworkParams(BaseSettings):
"""A set of parameters that describe an IPv4 network in InfoBlox.""" """A set of parameters that describe an IPv4 network in InfoBlox."""
containers: list[ipaddress.IPv4Network] containers: list[ipaddress.IPv4Network]
networks: list[ipaddress.IPv4Network] networks: list[ipaddress.IPv4Network]
mask: int # TODO: validation on mask? mask: V4Netmask
class V6NetworkParams(BaseSettings): class V6NetworkParams(BaseSettings):
...@@ -44,7 +53,7 @@ class V6NetworkParams(BaseSettings): ...@@ -44,7 +53,7 @@ class V6NetworkParams(BaseSettings):
containers: list[ipaddress.IPv6Network] containers: list[ipaddress.IPv6Network]
networks: list[ipaddress.IPv6Network] networks: list[ipaddress.IPv6Network]
mask: int # TODO: validation on mask? mask: V6Netmask
class ServiceNetworkParams(BaseSettings): class ServiceNetworkParams(BaseSettings):
...@@ -75,7 +84,9 @@ class ProvisioningProxyParams(BaseSettings): ...@@ -75,7 +84,9 @@ class ProvisioningProxyParams(BaseSettings):
scheme: str scheme: str
api_base: str api_base: str
auth: str # FIXME: unfinished #: .. deprecated:: 0.1
#: Not used anymore, may be left out from config file.
auth: str | None
api_version: int api_version: int
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment