diff --git a/README.md b/README.md index 2056188cc7ff88eb1e9cb0a4cbfe05a65130af32..cb0cac396dac06de905b67f05d408286af9365c1 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,4 @@ The GÉANT interpretation of [`orchestrator-core`](https://github.com/workfloworchestrator/orchestrator-core). ## 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). diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index bb7a2a69ca2c76900be5d138f6f3db610528538b..b98edde4222ab7f001fba8b36dfcc750d1821a8e 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -3,6 +3,9 @@ Glossary of terms .. glossary:: + API + Application Programming Interface + BGP Border Gateway Protocol: a path vector routing protocol described in `RFC 4271 <https://datatracker.ietf.org/doc/html/rfc4271>`_. diff --git a/docs/source/module/products/index.rst b/docs/source/module/products/index.rst index c68072cc071e570f268408b67e656b63a1ed4bf6..415b1c48357cd59d9a59fbd372be22cf11ac7b2c 100644 --- a/docs/source/module/products/index.rst +++ b/docs/source/module/products/index.rst @@ -1,6 +1,10 @@ ``gso.products`` ================ +.. automodule:: gso.products + :members: + :show-inheritance: + Subpackages ----------- diff --git a/gso/api/v1/imports.py b/gso/api/v1/imports.py index bd8c0d5f2cb41b654e198d84f093d8d204714215..51f58c5d62705969f054a2d680aab9e806ff2171 100644 --- a/gso/api/v1/imports.py +++ b/gso/api/v1/imports.py @@ -1,17 +1,126 @@ +import ipaddress from typing import Any from uuid import UUID from fastapi import Depends, HTTPException, status from fastapi.routing import APIRouter 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 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)]) +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: """Start a process and handle common exceptions.""" @@ -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. """ 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") ) if subscription: diff --git a/gso/migrations/env.py b/gso/migrations/env.py index 2f41e241668abfc8989a2ff01d91cd5099fb0bc4..a1f9b9fcdbb9088dc846a4273a9a12eff786baf6 100644 --- a/gso/migrations/env.py +++ b/gso/migrations/env.py @@ -1,11 +1,11 @@ import logging import os -from alembic import context -from sqlalchemy import engine_from_config, pool import orchestrator +from alembic import context from orchestrator.db.database import BaseModel from orchestrator.settings import app_settings +from sqlalchemy import engine_from_config, pool # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -61,7 +61,7 @@ def run_migrations_online() -> None: # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # 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): script = directives[0] if script.upgrade_ops.is_empty(): diff --git a/gso/migrations/versions/2023-10-11_394dc60d5c02_modify_ip_trunk_model.py b/gso/migrations/versions/2023-10-11_394dc60d5c02_modify_ip_trunk_model.py new file mode 100644 index 0000000000000000000000000000000000000000..ce76bb6d3cb8d701bda8d69e0f474a81ec2e288e --- /dev/null +++ b/gso/migrations/versions/2023-10-11_394dc60d5c02_modify_ip_trunk_model.py @@ -0,0 +1,86 @@ +"""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') + """)) diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json index a06212ef7441a31055fdc0b240982aa9c2c00bf5..771492f66dd9555588215cf6b7f88edd51b9a14d 100644 --- a/gso/oss-params-example.json +++ b/gso/oss-params-example.json @@ -48,7 +48,6 @@ "PROVISIONING_PROXY": { "scheme": "https", "api_base": "localhost:44444", - "auth": "Bearer <token>", "api_version": 1123 } } diff --git a/gso/products/__init__.py b/gso/products/__init__.py index df2529a3ff74a248f29490cd6fa49bbdd318fb65..e6a8c06f8850748b36233be63006a8fa9709d946 100644 --- a/gso/products/__init__.py +++ b/gso/products/__init__.py @@ -1,10 +1,23 @@ -"""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 pydantic_forms.types import strEnum from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.router import Router from gso.products.product_types.site import Site + +class ProductType(strEnum): + SITE = "Site" + ROUTER = "Router" + IP_TRUNK = "IP trunk" + + SUBSCRIPTION_MODEL_REGISTRY.update( { "Site": Site, @@ -12,5 +25,3 @@ SUBSCRIPTION_MODEL_REGISTRY.update( "IP trunk": Iptrunk, } ) - -__all__ = ["Site", "Iptrunk", "Router"] diff --git a/gso/products/product_blocks/__init__.py b/gso/products/product_blocks/__init__.py index de5db46c80c80ee31acfdf549159cde5f4e862b2..304dbd923eb76757961ac7e893cf2dbd564e8a28 100644 --- a/gso/products/product_blocks/__init__.py +++ b/gso/products/product_blocks/__init__.py @@ -1,18 +1 @@ -"""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" +"""Product blocks that store information about subscriptions.""" diff --git a/gso/products/product_blocks/iptrunk.py b/gso/products/product_blocks/iptrunk.py index 3eb992d189955e52ea7ee3fce27c55658fd98f1c..7fab56c0d20be1d1adb9e9b65ffebd8049caa1ff 100644 --- a/gso/products/product_blocks/iptrunk.py +++ b/gso/products/product_blocks/iptrunk.py @@ -6,11 +6,22 @@ from typing import TypeVar from orchestrator.domain.base import ProductBlockModel from orchestrator.forms.validators import UniqueConstrainedList from orchestrator.types import SubscriptionLifecycle, strEnum -from pydantic import Field 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): DARK_FIBER = "Dark_fiber" LEASED = "Leased" @@ -19,6 +30,28 @@ class IptrunkType(strEnum): 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] min_items = 2 max_items = 2 @@ -30,24 +63,21 @@ class IptrunkSideBlockInactive( iptrunk_side_node: RouterBlockInactive iptrunk_side_ae_iface: 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_description: list[str] = Field(default_factory=list) + iptrunk_side_ae_members: LAGMemberList[IptrunkInterfaceBlockInactive] class IptrunkSideBlockProvisioning(IptrunkSideBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): iptrunk_side_node: RouterBlockProvisioning iptrunk_side_ae_iface: 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_description: list[str] = Field(default_factory=list) + iptrunk_side_ae_members: LAGMemberList[IptrunkInterfaceBlockProvisioning] class IptrunkSideBlock(IptrunkSideBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): iptrunk_side_node: RouterBlock iptrunk_side_ae_iface: 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_description: list[str] = Field(default_factory=list) + iptrunk_side_ae_members: LAGMemberList[IptrunkInterfaceBlock] class IptrunkBlockInactive( @@ -58,12 +88,11 @@ class IptrunkBlockInactive( geant_s_sid: str | None = None iptrunk_description: str | None = None iptrunk_type: IptrunkType | None = None - iptrunk_speed: str | None = None + iptrunk_speed: PhyPortCapacity | None = None iptrunk_minimum_links: int | None = None iptrunk_isis_metric: int | None = None iptrunk_ipv4_network: ipaddress.IPv4Network | None = None iptrunk_ipv6_network: ipaddress.IPv6Network | None = None - # iptrunk_sides: IptrunkSides[IptrunkSideBlockInactive] @@ -73,12 +102,11 @@ class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLife geant_s_sid: str | None = None iptrunk_description: str | None = None iptrunk_type: IptrunkType | None = None - iptrunk_speed: str | None = None + iptrunk_speed: PhyPortCapacity | None = None iptrunk_minimum_links: int | None = None iptrunk_isis_metric: int | None = None iptrunk_ipv4_network: ipaddress.IPv4Network | None = None iptrunk_ipv6_network: ipaddress.IPv6Network | None = None - # iptrunk_sides: IptrunkSides[IptrunkSideBlockProvisioning] @@ -92,7 +120,7 @@ class IptrunkBlock(IptrunkBlockProvisioning, lifecycle=[SubscriptionLifecycle.AC #: The type of trunk, can be either dark fibre or leased capacity. iptrunk_type: IptrunkType #: 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. iptrunk_minimum_links: int #: The :term:`IS-IS` metric of this link diff --git a/gso/products/product_blocks/router.py b/gso/products/product_blocks/router.py index 58cf1d3602ffe3a10bc226f71064c88286a0bbf4..a8a820448a1b7388b903b0be69b7da9d4c17d660 100644 --- a/gso/products/product_blocks/router.py +++ b/gso/products/product_blocks/router.py @@ -3,9 +3,9 @@ import ipaddress from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum +from pydantic import ConstrainedInt from gso.products.product_blocks.site import SiteBlock, SiteBlockInactive, SiteBlockProvisioning -from gso.products.shared import PortNumber class RouterVendor(strEnum): @@ -23,6 +23,16 @@ class RouterRole(strEnum): 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( ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="RouterBlock" ): @@ -92,5 +102,5 @@ class RouterBlock(RouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTI router_role: RouterRole #: The :class:`Site` that this router resides in. Both physically and computationally. 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 diff --git a/gso/products/product_blocks/site.py b/gso/products/product_blocks/site.py index 74f4cf25dd1266424e222f301439a0a9f3dde694..739e1a0a91629b6dc2e427bfbd6ccd93daa8bb63 100644 --- a/gso/products/product_blocks/site.py +++ b/gso/products/product_blocks/site.py @@ -1,8 +1,10 @@ """The product block that describes a site subscription.""" +import re +from typing import Union + from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum - -from gso.schemas.types import LatitudeCoordinate, LongitudeCoordinate +from pydantic import ConstrainedStr class SiteTier(strEnum): @@ -18,6 +20,42 @@ class SiteTier(strEnum): 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"): """A site that's currently inactive, see :class:`SiteBlock`.""" diff --git a/gso/products/shared.py b/gso/products/shared.py deleted file mode 100644 index 81e080618557b4431b1c58df1c9f9c172d79078f..0000000000000000000000000000000000000000 --- a/gso/products/shared.py +++ /dev/null @@ -1,11 +0,0 @@ -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 diff --git a/gso/schemas/enums.py b/gso/schemas/enums.py deleted file mode 100644 index c803ede4acf8757199d5f5e28fe37576378a9dea..0000000000000000000000000000000000000000 --- a/gso/schemas/enums.py +++ /dev/null @@ -1,11 +0,0 @@ -from orchestrator.types import strEnum - - -class ProductType(strEnum): - SITE = "Site" - ROUTER = "Router" - IP_TRUNK = "IP trunk" - - -class SubscriptionStatus(strEnum): - ACTIVE = "active" diff --git a/gso/schemas/imports.py b/gso/schemas/imports.py deleted file mode 100644 index da1e242aab47d85abb7bc01d18cf1a51eaf034d8..0000000000000000000000000000000000000000 --- a/gso/schemas/imports.py +++ /dev/null @@ -1,125 +0,0 @@ -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 diff --git a/gso/schemas/types.py b/gso/schemas/types.py deleted file mode 100644 index 114e573611b457a6adf3360517c3bd599021e08b..0000000000000000000000000000000000000000 --- a/gso/schemas/types.py +++ /dev/null @@ -1,40 +0,0 @@ -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 diff --git a/gso/services/crm.py b/gso/services/crm.py index 8c2af4698aa2c16478f372388df88ae6d125c308..5ccd3e45f80b0febf706f001f5e85e71c1c38bd9 100644 --- a/gso/services/crm.py +++ b/gso/services/crm.py @@ -1,5 +1,7 @@ from typing import Any +from pydantic_forms.validators import Choice + class CustomerNotFoundError(Exception): """Exception raised when a customer is not found.""" @@ -22,3 +24,11 @@ def get_customer_by_name(name: str) -> dict[str, Any]: return customer 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] diff --git a/gso/services/netbox_client.py b/gso/services/netbox_client.py index 45b0906b2ad28c212b995de483810c51052b02b5..02f5313a94d88e48447335c7945da5e14ea0aabb 100644 --- a/gso/services/netbox_client.py +++ b/gso/services/netbox_client.py @@ -5,13 +5,12 @@ import pydantic import pynetbox 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.utils.device_info import DEFAULT_SITE, FEASIBLE_IP_TRUNK_LAG_RANGE, ROUTER_ROLE, TierInfo from gso.utils.exceptions import NotFoundError, WorkflowStateError -# Define device models class Manufacturer(pydantic.BaseModel): """Defines the manufacturer of a device.""" @@ -22,8 +21,7 @@ class Manufacturer(pydantic.BaseModel): class DeviceType(pydantic.BaseModel): """Defines the device type. - The manufacturer should be created first to get the manufacturer id, - which is defined here as int. + The manufacturer should be created first to get the manufacturer id, which is defined here as int. """ manufacturer: int @@ -46,7 +44,7 @@ class Site(pydantic.BaseModel): 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: self.netbox_params = load_oss_params().NETBOX @@ -56,7 +54,7 @@ class NetboxClient: return list(self.netbox.dcim.devices.all()) 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) def get_interfaces_by_device(self, device_name: str, speed: str) -> list[Interfaces]: @@ -87,11 +85,13 @@ class NetboxClient: ) 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 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)) def create_device_role(self, name: str, slug: str) -> DeviceRole: @@ -116,7 +116,7 @@ class NetboxClient: return None 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 tier_info = TierInfo().get_module_by_name(f"Tier{site_tier}") @@ -159,7 +159,7 @@ class NetboxClient: def attach_interface_to_lag( self, device_name: str, lag_name: str, iface_name: str, description: str | None = None ) -> Interfaces: - """Assign a given interface to a LAG. + """Assign a given interface to a :term:`LAG`. Returns the interface object after assignment. """ @@ -225,20 +225,20 @@ class NetboxClient: return interface 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 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 = [ 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] - # 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] @staticmethod diff --git a/gso/services/subscriptions.py b/gso/services/subscriptions.py index 5e234813f34dba4818da1e7504e0d33a023070ab..f1c6b075a6fe211217ab3ad407a1ef371df47d9c 100644 --- a/gso/services/subscriptions.py +++ b/gso/services/subscriptions.py @@ -8,14 +8,12 @@ from orchestrator.db import ( SubscriptionInstanceValueTable, SubscriptionTable, ) +from orchestrator.types import SubscriptionLifecycle -from gso.schemas.enums import ProductType, SubscriptionStatus +from gso.products import ProductType -def get_active_subscriptions( - product_type: str, - fields: list[str], -) -> list[Subscription]: +def get_active_subscriptions(product_type: str, fields: list[str]) -> list[Subscription]: """Retrieve active subscriptions for a specific product type. :param product_type: The type of the product for which to retrieve subscriptions. @@ -32,7 +30,7 @@ def get_active_subscriptions( SubscriptionTable.query.join(ProductTable) .filter( ProductTable.product_type == product_type, - SubscriptionTable.status == SubscriptionStatus.ACTIVE, + SubscriptionTable.status == SubscriptionLifecycle.ACTIVE, ) .with_entities(*dynamic_fields) .all() @@ -91,6 +89,6 @@ def get_active_site_subscription_by_name(site_name: str) -> Subscription: .join(ResourceTypeTable) .filter(SubscriptionInstanceValueTable.value == site_name) .filter(ResourceTypeTable.resource_type == "site_name") - .filter(SubscriptionTable.status == SubscriptionStatus.ACTIVE) + .filter(SubscriptionTable.status == SubscriptionLifecycle.ACTIVE) .first() ) diff --git a/gso/settings.py b/gso/settings.py index 701f65fddc74029e2fba309d76f5ec275fd1d9e8..d7b48048872b8de9cc4109fb3eb969ddc41f8ca4 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -1,6 +1,7 @@ """: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 @@ -8,7 +9,7 @@ import json import logging import os -from pydantic import BaseSettings +from pydantic import BaseSettings, NonNegativeInt logger = logging.getLogger(__name__) @@ -31,12 +32,20 @@ class InfoBloxParams(BaseSettings): password: str +class V4Netmask(NonNegativeInt): + le = 32 + + +class V6Netmask(NonNegativeInt): + le = 128 + + class V4NetworkParams(BaseSettings): """A set of parameters that describe an IPv4 network in InfoBlox.""" containers: list[ipaddress.IPv4Network] networks: list[ipaddress.IPv4Network] - mask: int # TODO: validation on mask? + mask: V4Netmask class V6NetworkParams(BaseSettings): @@ -44,7 +53,7 @@ class V6NetworkParams(BaseSettings): containers: list[ipaddress.IPv6Network] networks: list[ipaddress.IPv6Network] - mask: int # TODO: validation on mask? + mask: V6Netmask class ServiceNetworkParams(BaseSettings): @@ -75,7 +84,9 @@ class ProvisioningProxyParams(BaseSettings): scheme: 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 diff --git a/gso/workflows/utils.py b/gso/utils/helpers.py similarity index 64% rename from gso/workflows/utils.py rename to gso/utils/helpers.py index cd461b81131917b704fcc6b7875f8a2ed7264470..ff37395b92b3c3d7a8c9b5c61e392afa7b7bbe31 100644 --- a/gso/workflows/utils.py +++ b/gso/utils/helpers.py @@ -2,21 +2,38 @@ import re from ipaddress import IPv4Address from uuid import UUID -from orchestrator.forms.validators import Choice -from orchestrator.types import UUIDstr +from orchestrator import step +from orchestrator.types import State, UUIDstr +from pydantic import BaseModel +from pydantic_forms.validators import Choice from gso.products.product_blocks.router import RouterVendor +from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.router import Router -from gso.services.crm import all_customers +from gso.services import provisioning_proxy from gso.services.netbox_client import NetboxClient -def customer_selector() -> Choice: - customers = {} - for customer in all_customers(): - customers[customer["id"]] = customer["name"] +class LAGMember(BaseModel): + # TODO: validate interface name + interface_name: str + interface_description: str - return Choice("Select a customer", zip(customers.keys(), customers.items())) # type: ignore[arg-type] + def __hash__(self) -> int: + return hash((self.interface_name, self.interface_description)) + + +@step("[COMMIT] Set ISIS metric to 90000") +def set_isis_to_90000(subscription: Iptrunk, process_id: UUIDstr, tt_number: str) -> State: + old_isis_metric = subscription.iptrunk.iptrunk_isis_metric + subscription.iptrunk.iptrunk_isis_metric = 90000 + provisioning_proxy.provision_ip_trunk(subscription, process_id, tt_number, "isis_interface", False) + + return { + "subscription": subscription, + "old_isis_metric": old_isis_metric, + "label_text": "ISIS is being set to 90K by the provisioning proxy, please wait for the results", + } def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: @@ -90,3 +107,20 @@ def validate_router_in_netbox(subscription_id: UUIDstr) -> UUIDstr | None: if not device: raise ValueError("The selected router does not exist in Netbox.") return subscription_id + + +def validate_iptrunk_unique_interface(interfaces: list[LAGMember]) -> list[LAGMember]: + """Verify if the interfaces are unique. + + Args: + ---- + interfaces (list[LAGMember]): The list of interfaces. + + Returns: + ------- + list[LAGMember]: The list of interfaces or raises an error. + """ + interface_names = [member.interface_name for member in interfaces] + if len(interface_names) != len(set(interface_names)): + raise ValueError("Interfaces must be unique.") + return interfaces diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index b08da1292fc6f34459a8af0419cc14d21c251eb6..f022181b5060632807b5c67aa9548aba9876505e 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -1,5 +1,7 @@ +from uuid import uuid4 + from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice, ChoiceList, UniqueConstrainedList +from orchestrator.forms.validators import Choice, UniqueConstrainedList from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr from orchestrator.workflow import StepList, done, init, step, workflow @@ -8,19 +10,20 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form from pydantic import validator from pynetbox.models.dcim import Interfaces -from gso.products.product_blocks import PhyPortCapacity -from gso.products.product_blocks.iptrunk import IptrunkType +from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlockInactive, IptrunkType, PhyPortCapacity from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning from gso.products.product_types.router import Router from gso.services import infoblox, provisioning_proxy, subscriptions +from gso.services.crm import customer_selector from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import pp_interaction -from gso.workflows.utils import ( +from gso.utils.helpers import ( + LAGMember, available_interfaces_choices, available_lags_choices, - customer_selector, get_router_vendor, + validate_iptrunk_unique_interface, validate_router_in_netbox, ) @@ -49,84 +52,95 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: initial_user_input = yield CreateIptrunkForm - router_enum_a = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore + router_enum_a = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] class SelectRouterSideA(FormPage): class Config: title = "Select a router for side A of the trunk." - iptrunk_sideA_node_id: router_enum_a # type: ignore[valid-type] + side_a_node_id: router_enum_a # type: ignore[valid-type] - @validator("iptrunk_sideA_node_id", allow_reuse=True) - def validate_device_exists_in_netbox(cls, iptrunk_sideA_node_id: UUIDstr) -> str | None: - return validate_router_in_netbox(iptrunk_sideA_node_id) + @validator("side_a_node_id", allow_reuse=True) + def validate_device_exists_in_netbox(cls, side_a_node_id: UUIDstr) -> str | None: + return validate_router_in_netbox(side_a_node_id) user_input_router_side_a = yield SelectRouterSideA - router_a = user_input_router_side_a.iptrunk_sideA_node_id.name - side_a_ae_iface = available_lags_choices(router_a) or str + router_a = user_input_router_side_a.side_a_node_id.name - class AeMembersListA(ChoiceList): + class JuniperAeMembers(UniqueConstrainedList[LAGMember]): min_items = initial_user_input.iptrunk_minimum_links - item_type = available_interfaces_choices(router_a, initial_user_input.iptrunk_speed) # type: ignore - unique_items = True - class JuniperAeMembers(UniqueConstrainedList[str]): - min_items = initial_user_input.iptrunk_minimum_links - unique_items = True + if get_router_vendor(router_a) == RouterVendor.NOKIA: - ae_members_side_a = AeMembersListA if get_router_vendor(router_a) == RouterVendor.NOKIA else JuniperAeMembers + class NokiaLAGMemberA(LAGMember): + interface_name: available_interfaces_choices( # type: ignore[valid-type] + router_a, initial_user_input.iptrunk_speed + ) - class AeMembersDescriptionListA(UniqueConstrainedList[str]): - min_items = initial_user_input.iptrunk_minimum_links + class NokiaAeMembersA(UniqueConstrainedList[NokiaLAGMemberA]): + min_items = initial_user_input.iptrunk_minimum_links + + ae_members_side_a = NokiaAeMembersA + else: + ae_members_side_a = JuniperAeMembers # type: ignore[assignment] class CreateIptrunkSideAForm(FormPage): class Config: title = "Provide subscription details for side A of the trunk." - iptrunk_sideA_ae_iface: side_a_ae_iface # type: ignore[valid-type] - iptrunk_sideA_ae_geant_a_sid: str - iptrunk_sideA_ae_members: ae_members_side_a # type: ignore[valid-type] - iptrunk_sideA_ae_members_descriptions: AeMembersDescriptionListA + side_a_ae_iface: available_lags_choices(router_a) or str # type: ignore[valid-type] + side_a_ae_geant_a_sid: str + side_a_ae_members: ae_members_side_a # type: ignore[valid-type] + + @validator("side_a_ae_members", allow_reuse=True) + def validate_iptrunk_unique_interface_side_a(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: + return validate_iptrunk_unique_interface(side_a_ae_members) user_input_side_a = yield CreateIptrunkSideAForm # Remove the selected router for side A, to prevent any loops - routers.pop(str(user_input_router_side_a.iptrunk_sideA_node_id.name)) - router_enum_b = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore + routers.pop(str(router_a)) + router_enum_b = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] class SelectRouterSideB(FormPage): class Config: title = "Select a router for side B of the trunk." - iptrunk_sideB_node_id: router_enum_b # type: ignore[valid-type] + side_b_node_id: router_enum_b # type: ignore[valid-type] - @validator("iptrunk_sideB_node_id", allow_reuse=True) - def validate_device_exists_in_netbox(cls, iptrunk_sideB_node_id: UUIDstr) -> str | None: - return validate_router_in_netbox(iptrunk_sideB_node_id) + @validator("side_b_node_id", allow_reuse=True) + def validate_device_exists_in_netbox(cls, side_b_node_id: UUIDstr) -> str | None: + return validate_router_in_netbox(side_b_node_id) user_input_router_side_b = yield SelectRouterSideB - router_b = user_input_router_side_b.iptrunk_sideB_node_id.name - side_b_ae_iface = available_lags_choices(router_b) or str + router_b = user_input_router_side_b.side_b_node_id.name + + if get_router_vendor(router_b) == RouterVendor.NOKIA: - class AeMembersListB(ChoiceList): - min_items = len(user_input_side_a.iptrunk_sideA_ae_members) - max_items = len(user_input_side_a.iptrunk_sideA_ae_members) - item_type = available_interfaces_choices(router_b, initial_user_input.iptrunk_speed) # type: ignore - unique_items = True + class NokiaLAGMemberB(LAGMember): + interface_name: available_interfaces_choices( # type: ignore[valid-type] + router_b, initial_user_input.iptrunk_speed + ) - ae_members_side_b = AeMembersListB if get_router_vendor(router_b) == RouterVendor.NOKIA else JuniperAeMembers + class NokiaAeMembersB(UniqueConstrainedList): + min_items = len(user_input_side_a.side_a_ae_members) + max_items = len(user_input_side_a.side_a_ae_members) + item_type = NokiaLAGMemberB - class AeMembersDescriptionListB(UniqueConstrainedList[str]): - min_items = len(user_input_side_a.iptrunk_sideA_ae_members) - max_items = len(user_input_side_a.iptrunk_sideA_ae_members) + ae_members_side_b = NokiaAeMembersB + else: + ae_members_side_b = JuniperAeMembers # type: ignore[assignment] class CreateIptrunkSideBForm(FormPage): class Config: title = "Provide subscription details for side B of the trunk." - iptrunk_sideB_ae_iface: side_b_ae_iface # type: ignore[valid-type] - iptrunk_sideB_ae_geant_a_sid: str - iptrunk_sideB_ae_members: ae_members_side_b # type: ignore[valid-type] - iptrunk_sideB_ae_members_descriptions: AeMembersDescriptionListB + side_b_ae_iface: available_lags_choices(router_b) or str # type: ignore[valid-type] + side_b_ae_geant_a_sid: str + side_b_ae_members: ae_members_side_b # type: ignore[valid-type] + + @validator("side_b_ae_members", allow_reuse=True) + def validate_iptrunk_unique_interface_side_b(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: + return validate_iptrunk_unique_interface(side_b_ae_members) user_input_side_b = yield CreateIptrunkSideBForm @@ -167,37 +181,39 @@ def initialize_subscription( geant_s_sid: str, iptrunk_type: IptrunkType, iptrunk_description: str, - iptrunk_speed: 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], + side_a_node_id: str, + side_a_ae_iface: str, + side_a_ae_geant_a_sid: str, + side_a_ae_members: list[dict], + side_b_node_id: str, + side_b_ae_iface: str, + side_b_ae_geant_a_sid: str, + side_b_ae_members: list[dict], ) -> State: subscription.iptrunk.geant_s_sid = geant_s_sid subscription.iptrunk.iptrunk_description = iptrunk_description subscription.iptrunk.iptrunk_type = iptrunk_type subscription.iptrunk.iptrunk_speed = iptrunk_speed - subscription.iptrunk.iptrunk_isis_metric = 9000 + subscription.iptrunk.iptrunk_isis_metric = 90000 subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node = Router.from_subscription(iptrunk_sideA_node_id).router - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface = iptrunk_sideA_ae_iface - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid = iptrunk_sideA_ae_geant_a_sid - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members = iptrunk_sideA_ae_members - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members_description = iptrunk_sideA_ae_members_descriptions - - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node = Router.from_subscription(iptrunk_sideB_node_id).router - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_iface = iptrunk_sideB_ae_iface - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid = iptrunk_sideB_ae_geant_a_sid - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members = iptrunk_sideB_ae_members - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members_description = iptrunk_sideB_ae_members_descriptions + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node = Router.from_subscription(side_a_node_id).router + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface = side_a_ae_iface + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid = side_a_ae_geant_a_sid + for member in side_a_ae_members: + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members.append( + IptrunkInterfaceBlockInactive.new(subscription_id=uuid4(), **member) + ) + + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node = Router.from_subscription(side_b_node_id).router + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_iface = side_b_ae_iface + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid = side_b_ae_geant_a_sid + for member in side_b_ae_members: + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members.append( + IptrunkInterfaceBlockInactive.new(subscription_id=uuid4(), **member) + ) subscription.description = f"IP trunk, geant_s_sid:{geant_s_sid}" subscription = IptrunkProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) @@ -287,12 +303,12 @@ def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: nbclient.attach_interface_to_lag( device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, lag_name=lag_interface.name, - iface_name=interface, + iface_name=interface.interface_name, description=str(subscription.subscription_id), ) nbclient.reserve_interface( device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, - iface_name=interface, + iface_name=interface.interface_name, ) return { "subscription": subscription, @@ -308,7 +324,7 @@ def allocate_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: for interface in subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_members: NetboxClient().allocate_interface( device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, - iface_name=interface, + iface_name=interface.interface_name, ) return { "subscription": subscription, diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 3b2c1b4d160dcfa3a51815c6b1b898f7e38e4dfb..4b918d00326114ed1da4f1b87a2a055f406bfe52 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -18,7 +18,7 @@ from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.router import Router from gso.services import provisioning_proxy from gso.services.provisioning_proxy import pp_interaction -from gso.workflows.iptrunk.utils import set_isis_to_90000 +from gso.utils.helpers import set_isis_to_90000 logger = getLogger(__name__) diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 87e3b8f52108b4dca6fd6f4aaba81127f6a3a2e4..284602504eb7af3d7d7563728fbe401a1ce0f424 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -1,4 +1,5 @@ import ipaddress +from uuid import uuid4 from orchestrator.forms import FormPage, ReadOnlyField from orchestrator.forms.validators import UniqueConstrainedList @@ -8,11 +9,11 @@ from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form -from gso.products.product_blocks import PhyPortCapacity -from gso.products.product_blocks.iptrunk import IptrunkType +from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkType, PhyPortCapacity from gso.products.product_types.iptrunk import Iptrunk from gso.services import provisioning_proxy from gso.services.provisioning_proxy import pp_interaction +from gso.utils.helpers import LAGMember def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -23,7 +24,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: geant_s_sid: str = subscription.iptrunk.geant_s_sid iptrunk_description: str = subscription.iptrunk.iptrunk_description iptrunk_type: IptrunkType = subscription.iptrunk.iptrunk_type - iptrunk_speed: PhyPortCapacity = subscription.iptrunk.iptrunk_speed # type: ignore[assignment] + iptrunk_speed: PhyPortCapacity = subscription.iptrunk.iptrunk_speed iptrunk_minimum_links: int = subscription.iptrunk.iptrunk_minimum_links iptrunk_isis_metric: int = ReadOnlyField(subscription.iptrunk.iptrunk_isis_metric) iptrunk_ipv4_network: ipaddress.IPv4Network = ReadOnlyField(subscription.iptrunk.iptrunk_ipv4_network) @@ -31,38 +32,32 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: initial_user_input = yield ModifyIptrunkForm - class AeMembersListA(UniqueConstrainedList[str]): + class AeMembersListA(UniqueConstrainedList[LAGMember]): min_items = initial_user_input.iptrunk_minimum_links class ModifyIptrunkSideAForm(FormPage): class Config: title = "Provide subscription details for side A of the trunk." - iptrunk_sideA_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn) - iptrunk_sideA_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface) - iptrunk_sideA_ae_geant_a_sid: str = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid - iptrunk_sideA_ae_members: AeMembersListA = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members - iptrunk_sideA_ae_members_descriptions: AeMembersListA = subscription.iptrunk.iptrunk_sides[ - 0 - ].iptrunk_side_ae_members_description + side_a_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn) + side_a_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface) + side_a_ae_geant_a_sid: str = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid + side_a_ae_members: AeMembersListA = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members user_input_side_a = yield ModifyIptrunkSideAForm - class AeMembersListB(UniqueConstrainedList[str]): - min_items = len(user_input_side_a.iptrunk_sideA_ae_members) - max_items = len(user_input_side_a.iptrunk_sideA_ae_members) + class AeMembersListB(UniqueConstrainedList[LAGMember]): + min_items = len(user_input_side_a.side_a_ae_members) + max_items = len(user_input_side_a.side_a_ae_members) class ModifyIptrunkSideBForm(FormPage): class Config: title = "Provide subscription details for side B of the trunk." - iptrunk_sideB_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn) - iptrunk_sideB_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_iface) - iptrunk_sideB_ae_geant_a_sid: str = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid - iptrunk_sideB_ae_members: AeMembersListB = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members - iptrunk_sideB_ae_members_descriptions: AeMembersListB = subscription.iptrunk.iptrunk_sides[ - 1 - ].iptrunk_side_ae_members_description + side_b_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn) + side_b_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_iface) + side_b_ae_geant_a_sid: str = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid + side_b_ae_members: AeMembersListB = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members user_input_side_b = yield ModifyIptrunkSideBForm @@ -75,14 +70,12 @@ def modify_iptrunk_subscription( geant_s_sid: str, iptrunk_type: IptrunkType, iptrunk_description: str, - iptrunk_speed: str, + iptrunk_speed: PhyPortCapacity, iptrunk_minimum_links: int, - iptrunk_sideA_ae_geant_a_sid: str, - iptrunk_sideA_ae_members: list[str], - iptrunk_sideA_ae_members_descriptions: list[str], - iptrunk_sideB_ae_geant_a_sid: str, - iptrunk_sideB_ae_members: list[str], - iptrunk_sideB_ae_members_descriptions: list[str], + side_a_ae_geant_a_sid: str, + side_a_ae_members: list[dict], + side_b_ae_geant_a_sid: str, + side_b_ae_members: list[dict], ) -> State: subscription.iptrunk.geant_s_sid = geant_s_sid subscription.iptrunk.iptrunk_description = iptrunk_description @@ -90,13 +83,21 @@ def modify_iptrunk_subscription( subscription.iptrunk.iptrunk_speed = iptrunk_speed subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid = iptrunk_sideA_ae_geant_a_sid - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members = iptrunk_sideA_ae_members - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members_description = iptrunk_sideA_ae_members_descriptions - - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid = iptrunk_sideB_ae_geant_a_sid - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members = iptrunk_sideB_ae_members - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members_description = iptrunk_sideB_ae_members_descriptions + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid = side_a_ae_geant_a_sid + # Flush the old list of member interfaces + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members.clear() + # And update the list to only include the new member interfaces + for member in side_a_ae_members: + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members.append( + IptrunkInterfaceBlock.new(subscription_id=uuid4(), **member) + ) + + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid = side_b_ae_geant_a_sid + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members.clear() + for member in side_b_ae_members: + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members.append( + IptrunkInterfaceBlock.new(subscription_id=uuid4(), **member) + ) subscription.description = f"IP trunk, geant_s_sid:{geant_s_sid}" diff --git a/gso/workflows/iptrunk/terminate_iptrunk.py b/gso/workflows/iptrunk/terminate_iptrunk.py index b2310d89c9d1851f168e9c2521fc0052c0c3b74c..c0a0da62cd549962164e6d9cc6afb787a7f63f79 100644 --- a/gso/workflows/iptrunk/terminate_iptrunk.py +++ b/gso/workflows/iptrunk/terminate_iptrunk.py @@ -11,7 +11,7 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form from gso.products.product_types.iptrunk import Iptrunk from gso.services import infoblox, provisioning_proxy from gso.services.provisioning_proxy import pp_interaction -from gso.workflows.iptrunk.utils import set_isis_to_90000 +from gso.utils.helpers import set_isis_to_90000 def initial_input_form_generator() -> FormGenerator: diff --git a/gso/workflows/iptrunk/utils.py b/gso/workflows/iptrunk/utils.py deleted file mode 100644 index 690cfc58b692efb143cb0caa5ef22ac081c3c36d..0000000000000000000000000000000000000000 --- a/gso/workflows/iptrunk/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from orchestrator import step -from orchestrator.types import State, UUIDstr - -from gso.products.product_types.iptrunk import Iptrunk -from gso.services import provisioning_proxy - - -@step("[COMMIT] Set ISIS metric to 90000") -def set_isis_to_90000(subscription: Iptrunk, process_id: UUIDstr, tt_number: str) -> State: - old_isis_metric = subscription.iptrunk.iptrunk_isis_metric - subscription.iptrunk.iptrunk_isis_metric = 90000 - provisioning_proxy.provision_ip_trunk(subscription, process_id, tt_number, "isis_interface", False) - - return { - "subscription": subscription, - "old_isis_metric": old_isis_metric, - "label_text": "ISIS is being set to 90K by the provisioning proxy, please wait for the results", - } diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 91e7e82a3355af50e3159a1e9dc4fa524db83aff..79311e470df340386502cb94f7e13d7b67ddf901 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -11,14 +11,14 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc from orchestrator.workflows.utils import wrap_create_initial_input_form from pydantic import validator -from gso.products.product_blocks.router import RouterRole, RouterVendor, generate_fqdn +from gso.products.product_blocks.router import PortNumber, RouterRole, RouterVendor, generate_fqdn from gso.products.product_types.router import RouterInactive, RouterProvisioning from gso.products.product_types.site import Site -from gso.products.shared import PortNumber from gso.services import infoblox, provisioning_proxy, subscriptions +from gso.services.crm import customer_selector from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import pp_interaction -from gso.workflows.utils import customer_selector, iso_from_ipv4 +from gso.utils.helpers import iso_from_ipv4 def _site_selector() -> Choice: @@ -159,7 +159,8 @@ def provision_router_real(subscription: RouterProvisioning, process_id: UUIDstr, def create_netbox_device(subscription: RouterProvisioning) -> State: if subscription.router.router_vendor == RouterVendor.NOKIA: NetboxClient().create_device( - subscription.router.router_fqdn, subscription.router.router_site.site_tier # type: ignore + subscription.router.router_fqdn, + str(subscription.router.router_site.site_tier), # type: ignore[union-attr] ) return {"subscription": subscription, "label_text": "Creating NetBox device"} return {"subscription": subscription, "label_text": "Skipping NetBox device creation for Juniper router."} diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py index 4989823fc1d94ddd883654ae187753abca493f0a..47d09b414c13b94cf93a836da241fcc3c957fa39 100644 --- a/gso/workflows/router/terminate_router.py +++ b/gso/workflows/router/terminate_router.py @@ -57,6 +57,8 @@ def deprovision_lt_ips(subscription: Router) -> dict: @step("Remove configuration from router") def remove_config_from_router() -> None: + # FIXME: Add actual content + # TODO: update unit test accordingly pass diff --git a/gso/workflows/site/create_site.py b/gso/workflows/site/create_site.py index edcb7a01acfdf9cc5620ac4b95a27e90d4afb72d..429b0d7e489be69a980050c6b4698cddd1a1dd10 100644 --- a/gso/workflows/site/create_site.py +++ b/gso/workflows/site/create_site.py @@ -11,9 +11,9 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form from pydantic import validator from gso.products.product_blocks import site as site_pb +from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate from gso.products.product_types import site -from gso.schemas.types import LatitudeCoordinate, LongitudeCoordinate -from gso.workflows.utils import customer_selector +from gso.services.crm import customer_selector def initial_input_form_generator(product_name: str) -> FormGenerator: # noqa: C901 diff --git a/gso/workflows/tasks/import_iptrunk.py b/gso/workflows/tasks/import_iptrunk.py index f1c3e6cf03612892b08848a6deb5b0a67a0beba3..84c12aa6667962eb1a40184384fad34d0a85ba04 100644 --- a/gso/workflows/tasks/import_iptrunk.py +++ b/gso/workflows/tasks/import_iptrunk.py @@ -8,12 +8,12 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription -from gso.products.product_blocks import PhyPortCapacity -from gso.products.product_blocks.iptrunk import IptrunkType +from gso.products import ProductType +from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning -from gso.schemas.enums import ProductType from gso.services import subscriptions from gso.services.crm import get_customer_by_name +from gso.utils.helpers import LAGMember from gso.workflows.iptrunk.create_iptrunk import initialize_subscription @@ -42,17 +42,15 @@ def initial_input_form_generator() -> FormGenerator: iptrunk_speed: PhyPortCapacity iptrunk_minimum_links: int - iptrunk_sideA_node_id: RouterEnum # type: ignore[valid-type] - iptrunk_sideA_ae_iface: str - iptrunk_sideA_ae_geant_a_sid: str - iptrunk_sideA_ae_members: UniqueConstrainedList[str] - iptrunk_sideA_ae_members_descriptions: UniqueConstrainedList[str] + side_a_node_id: RouterEnum # type: ignore[valid-type] + side_a_ae_iface: str + side_a_ae_geant_a_sid: str + side_a_ae_members: UniqueConstrainedList[LAGMember] - iptrunk_sideB_node_id: RouterEnum # type: ignore[valid-type] - iptrunk_sideB_ae_iface: str - iptrunk_sideB_ae_geant_a_sid: str - iptrunk_sideB_ae_members: UniqueConstrainedList[str] - iptrunk_sideB_ae_members_descriptions: UniqueConstrainedList[str] + side_b_node_id: RouterEnum # type: ignore[valid-type] + side_b_ae_iface: str + side_b_ae_geant_a_sid: str + side_b_ae_members: UniqueConstrainedList[LAGMember] iptrunk_ipv4_network: ipaddress.IPv4Network iptrunk_ipv6_network: ipaddress.IPv6Network @@ -82,6 +80,7 @@ def update_ipam_stub_for_subscription( ) -> State: subscription.iptrunk.iptrunk_ipv4_network = iptrunk_ipv4_network subscription.iptrunk.iptrunk_ipv6_network = iptrunk_ipv6_network + subscription.iptrunk.iptrunk_ipv6_network = iptrunk_ipv6_network return {"subscription": subscription} diff --git a/gso/workflows/tasks/import_router.py b/gso/workflows/tasks/import_router.py index 0821e31414c822197cb419101562a4d74a5afaec..d7984b9264af68bed7815126bee445d29062daea 100644 --- a/gso/workflows/tasks/import_router.py +++ b/gso/workflows/tasks/import_router.py @@ -8,13 +8,12 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription +from gso.products import ProductType from gso.products.product_blocks import router as router_pb -from gso.products.product_blocks.router import RouterRole, RouterVendor +from gso.products.product_blocks.router import PortNumber, RouterRole, RouterVendor from gso.products.product_types import router from gso.products.product_types.router import RouterInactive from gso.products.product_types.site import Site -from gso.products.shared import PortNumber -from gso.schemas.enums import ProductType from gso.services import subscriptions from gso.services.crm import get_customer_by_name diff --git a/gso/workflows/tasks/import_site.py b/gso/workflows/tasks/import_site.py index 20967c71c2f12e16bd78081538eb89979fb1baf1..af96fca24e6e67d0ffac5013c593eed238a16706 100644 --- a/gso/workflows/tasks/import_site.py +++ b/gso/workflows/tasks/import_site.py @@ -6,9 +6,9 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription +from gso.products import ProductType from gso.products.product_blocks.site import SiteTier from gso.products.product_types.site import SiteInactive -from gso.schemas.enums import ProductType from gso.services import subscriptions from gso.services.crm import get_customer_by_name from gso.workflows.site.create_site import initialize_subscription diff --git a/pyproject.toml b/pyproject.toml index 7836077742ec1e38f3121726e4168387137e1169..19d2278563cff9f3b1bec5aad1fef0e57e4d18ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ show_error_codes = true show_column_numbers = true # Suppress "note: By default the bodies of untyped functions are not checked" disable_error_code = "annotation-unchecked" +# Forbid the use of a generic "type: ignore" without specifying the exact error that is ignored +enable_error_code = "ignore-without-code" [tool.ruff] exclude = [ diff --git a/test-docs.sh b/test-docs.sh index 7415bda463ac80b3ab548a0d457ea9d56d030d92..cdc50473e43e7e6c7356e89a5c994bac86f141c2 100755 --- a/test-docs.sh +++ b/test-docs.sh @@ -4,5 +4,5 @@ if [ ! -d ./docs/vale/styles/proselint ] || [ ! -d ./docs/vale/styles/Microsoft docker run -it --rm -v "$(pwd)"/docs:/docs jdkato/vale:latest --config="/docs/vale/.vale.ini" sync fi -docker run -it --rm -v $(pwd):/gso jdkato/vale:latest --glob='!*/migrations/*' \ +docker run -it --rm -v "$(pwd)":/gso jdkato/vale:latest --glob='!*/migrations/*' \ --config="/gso/docs/vale/.vale.ini" /gso/docs/source /gso/gso diff --git a/test/conftest.py b/test/conftest.py index 2bb17b7a4baf57e34ca19bfadcb70f0790cc5786..f46642a13a3babe429e5edcb3667659d65871223 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -47,6 +47,21 @@ class FakerProvider(BaseProvider): return ipaddress.IPv6Network(str(network) + "/64") + def tt_number(self) -> str: + random_date = self.generator.date(pattern="%Y%m%d") + random_int = self.generator.random_int(min=10000000, max=99999999) + + return f"TT#{random_date}{random_int}" + + def geant_gid(self) -> str: + return self.generator.numerify("GID-#####") + + def geant_sid(self) -> str: + return self.generator.numerify("SID-#####") + + def network_interface(self) -> str: + return self.generator.numerify("ge-@#/@#/@#") + @pytest.fixture(scope="session") def faker() -> Faker: diff --git a/test/fixtures.py b/test/fixtures.py index 2f3ba8993da821982e2792a3b12b125e88958ce6..ec2b2bd56179f2fa6e54dfe69bfa78d21a408c08 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -5,11 +5,13 @@ from orchestrator.db import db from orchestrator.domain import SubscriptionModel from orchestrator.types import SubscriptionLifecycle, UUIDstr +from gso.products import ProductType +from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkSideBlock, IptrunkType, PhyPortCapacity from gso.products.product_blocks.router import RouterRole, RouterVendor from gso.products.product_blocks.site import SiteTier -from gso.products.product_types.router import RouterInactive +from gso.products.product_types.iptrunk import IptrunkInactive +from gso.products.product_types.router import Router, RouterInactive from gso.products.product_types.site import Site, SiteInactive -from gso.schemas.enums import ProductType from gso.services import subscriptions CUSTOMER_ID: UUIDstr = "2f47f65a-0911-e511-80d0-005056956c1a" @@ -32,7 +34,7 @@ def site_subscription_factory(faker): site_ts_address=None, ) -> UUIDstr: description = description or "Site Subscription" - site_name = site_name or faker.name() + site_name = site_name or faker.domain_word() site_city = site_city or faker.city() site_country = site_country or faker.country() site_country_code = site_country_code or faker.country_code() @@ -86,7 +88,7 @@ def router_subscription_factory(site_subscription_factory, faker): router_is_ias_connected=True, ) -> UUIDstr: description = description or faker.text(max_nb_chars=30) - router_fqdn = router_fqdn or faker.domain_name() + router_fqdn = router_fqdn or faker.domain_name(levels=4) router_ts_port = router_ts_port or faker.random_int(min=1, max=49151) router_access_via_ts = router_access_via_ts or faker.boolean() router_lo_ipv4_address = router_lo_ipv4_address or ipaddress.IPv4Address(faker.ipv4()) @@ -122,3 +124,88 @@ def router_subscription_factory(site_subscription_factory, faker): return str(router_subscription.subscription_id) return subscription_create + + +@pytest.fixture +def iptrunk_side_subscription_factory(router_subscription_factory, faker): + def subscription_create( + iptrunk_side_node=None, + iptrunk_side_ae_iface=None, + iptrunk_side_ae_geant_a_sid=None, + iptrunk_side_ae_members=None, + iptrunk_side_ae_members_description=None, + ) -> IptrunkSideBlock: + iptrunk_side_node_id = iptrunk_side_node or router_subscription_factory() + iptrunk_side_node = Router.from_subscription(iptrunk_side_node_id).router + iptrunk_side_ae_iface = iptrunk_side_ae_iface or faker.pystr() + iptrunk_side_ae_geant_a_sid = iptrunk_side_ae_geant_a_sid or faker.geant_sid() + iptrunk_side_ae_members = iptrunk_side_ae_members or [ + IptrunkInterfaceBlock.new( + faker.uuid4(), interface_name=faker.network_interface(), interface_description=faker.sentence() + ), + IptrunkInterfaceBlock.new( + faker.uuid4(), interface_name=faker.network_interface(), interface_description=faker.sentence() + ), + ] + + return IptrunkSideBlock.new( + faker.uuid4(), + iptrunk_side_node=iptrunk_side_node, + iptrunk_side_ae_iface=iptrunk_side_ae_iface, + iptrunk_side_ae_geant_a_sid=iptrunk_side_ae_geant_a_sid, + iptrunk_side_ae_members=iptrunk_side_ae_members, + iptrunk_side_ae_members_description=iptrunk_side_ae_members_description, + ) + + return subscription_create + + +@pytest.fixture +def iptrunk_subscription_factory(iptrunk_side_subscription_factory, faker): + def subscription_create( + description=None, + start_date="2023-05-24T00:00:00+00:00", + geant_s_sid=None, + iptrunk_description=None, + iptrunk_type=IptrunkType.DARK_FIBER, + iptrunk_speed=PhyPortCapacity.ONE_GIGABIT_PER_SECOND, + iptrunk_isis_metric=None, + iptrunk_ipv4_network=None, + iptrunk_ipv6_network=None, + iptrunk_sides=None, + ) -> UUIDstr: + product_id = subscriptions.get_product_id_by_name(ProductType.IP_TRUNK) + description = description or faker.sentence() + + geant_s_sid = geant_s_sid or faker.geant_sid() + iptrunk_description = iptrunk_description or faker.sentence() + iptrunk_isis_metric = iptrunk_isis_metric or faker.pyint() + iptrunk_ipv4_network = iptrunk_ipv4_network or faker.ipv4_network() + iptrunk_ipv6_network = iptrunk_ipv6_network or faker.ipv6_network() + iptrunk_minimum_links = 1 + iptrunk_side_a = iptrunk_side_subscription_factory() + iptrunk_side_b = iptrunk_side_subscription_factory() + iptrunk_sides = iptrunk_sides or [iptrunk_side_a, iptrunk_side_b] + + iptrunk_subscription = IptrunkInactive.from_product_id(product_id, customer_id=CUSTOMER_ID, insync=True) + iptrunk_subscription.iptrunk.geant_s_sid = geant_s_sid + iptrunk_subscription.iptrunk.iptrunk_description = iptrunk_description + iptrunk_subscription.iptrunk.iptrunk_type = iptrunk_type + iptrunk_subscription.iptrunk.iptrunk_speed = iptrunk_speed + iptrunk_subscription.iptrunk.iptrunk_minimum_links = iptrunk_minimum_links + iptrunk_subscription.iptrunk.iptrunk_isis_metric = iptrunk_isis_metric + iptrunk_subscription.iptrunk.iptrunk_ipv4_network = iptrunk_ipv4_network + iptrunk_subscription.iptrunk.iptrunk_ipv6_network = iptrunk_ipv6_network + iptrunk_subscription.iptrunk.iptrunk_sides = iptrunk_sides + + iptrunk_subscription = SubscriptionModel.from_other_lifecycle( + iptrunk_subscription, SubscriptionLifecycle.ACTIVE + ) + iptrunk_subscription.description = description + iptrunk_subscription.start_date = start_date + iptrunk_subscription.save() + db.session.commit() + + return str(iptrunk_subscription.subscription_id) + + return subscription_create diff --git a/test/imports/conftest.py b/test/imports/conftest.py index 425a0e627a4592241e2c3f81cce910255dd34a5e..4a3e9c07a0c70ef31069d0f624c7437553562a16 100644 --- a/test/imports/conftest.py +++ b/test/imports/conftest.py @@ -1 +1,6 @@ -from test.fixtures import router_subscription_factory, site_subscription_factory # noqa +from test.fixtures import ( # noqa + iptrunk_side_subscription_factory, + iptrunk_subscription_factory, + router_subscription_factory, + site_subscription_factory, +) diff --git a/test/imports/test_imports.py b/test/imports/test_imports.py index c41497c026ad36d7391542542da103b04db49519..3cdfa3ed1d5f87abd72521198220a58687b7960d 100644 --- a/test/imports/test_imports.py +++ b/test/imports/test_imports.py @@ -5,10 +5,10 @@ import pytest from orchestrator.db import SubscriptionTable from orchestrator.services import subscriptions -from gso.products.product_blocks import PhyPortCapacity -from gso.products.product_blocks.iptrunk import IptrunkType +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.utils.helpers import iso_from_ipv4 SITE_IMPORT_ENDPOINT = "/api/v1/imports/sites" ROUTER_IMPORT_ENDPOINT = "/api/v1/imports/routers" @@ -21,21 +21,23 @@ def iptrunk_data(router_subscription_factory, faker): router_side_b = router_subscription_factory() return { "customer": "GÉANT", - "geant_s_sid": faker.pystr(), + "geant_s_sid": faker.geant_sid(), "iptrunk_type": IptrunkType.DARK_FIBER, "iptrunk_description": faker.sentence(), "iptrunk_speed": PhyPortCapacity.HUNDRED_GIGABIT_PER_SECOND, "iptrunk_minimum_links": 5, - "iptrunk_sideA_node_id": router_side_a, - "iptrunk_sideA_ae_iface": faker.pystr(), - "iptrunk_sideA_ae_geant_a_sid": faker.pystr(), - "iptrunk_sideA_ae_members": [faker.pystr() for _ in range(5)], - "iptrunk_sideA_ae_members_descriptions": [faker.sentence() for _ in range(5)], - "iptrunk_sideB_node_id": router_side_b, - "iptrunk_sideB_ae_iface": faker.pystr(), - "iptrunk_sideB_ae_geant_a_sid": faker.pystr(), - "iptrunk_sideB_ae_members": [faker.pystr() for _ in range(5)], - "iptrunk_sideB_ae_members_descriptions": [faker.sentence() for _ in range(5)], + "side_a_node_id": router_side_a, + "side_a_ae_iface": faker.network_interface(), + "side_a_ae_geant_a_sid": faker.geant_sid(), + "side_a_ae_members": [ + {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5) + ], + "side_b_node_id": router_side_b, + "side_b_ae_iface": faker.network_interface(), + "side_b_ae_geant_a_sid": faker.geant_sid(), + "side_b_ae_members": [ + {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5) + ], "iptrunk_ipv4_network": str(faker.ipv4_network()), "iptrunk_ipv6_network": str(faker.ipv6_network()), } @@ -43,23 +45,23 @@ def iptrunk_data(router_subscription_factory, faker): @pytest.fixture def mock_routers(iptrunk_data): - first_call = [iptrunk_data["iptrunk_sideA_node_id"], iptrunk_data["iptrunk_sideB_node_id"], str(uuid4())] - side_effects = [ - first_call, - first_call, - [ - (iptrunk_data["iptrunk_sideA_node_id"], "iptrunk_sideA_node_id description"), - (iptrunk_data["iptrunk_sideB_node_id"], "iptrunk_sideB_node_id description"), - (str(uuid4()), "random description"), - ], - ] with patch("gso.services.subscriptions.get_active_router_subscriptions") as mock_get_active_router_subscriptions: - mock_get_active_router_subscriptions.side_effect = side_effects + + def _active_router_subscriptions(*args, **kwargs): + if kwargs["fields"] == ["subscription_id", "description"]: + return [ + (iptrunk_data["side_a_node_id"], "side_a_node_id description"), + (iptrunk_data["side_b_node_id"], "side_b_node_id description"), + (str(uuid4()), "random description"), + ] + return [iptrunk_data["side_a_node_id"], iptrunk_data["side_b_node_id"], str(uuid4())] + + mock_get_active_router_subscriptions.side_effect = _active_router_subscriptions yield mock_get_active_router_subscriptions @patch("gso.api.v1.imports._start_process") -def test_import_iptrunk_successful_with_mocked_process(mock_start_process, test_client, iptrunk_data, mock_routers): +def test_import_iptrunk_successful_with_mocked_process(mock_start_process, test_client, mock_routers, iptrunk_data): mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) @@ -70,7 +72,7 @@ def test_import_iptrunk_successful_with_mocked_process(mock_start_process, test_ @pytest.fixture def site_data(faker): return { - "site_name": faker.name(), + "site_name": faker.domain_word(), "site_city": faker.city(), "site_country": faker.country(), "site_country_code": faker.country_code(), @@ -86,6 +88,7 @@ def site_data(faker): @pytest.fixture def router_data(faker, site_data): + mock_ipv4 = faker.ipv4() return { "hostname": "127.0.0.1", "router_role": RouterRole.PE, @@ -94,9 +97,9 @@ def router_data(faker, site_data): "ts_port": 1234, "customer": "GÉANT", "is_ias_connected": True, - "router_lo_ipv4_address": faker.ipv4(), + "router_lo_ipv4_address": mock_ipv4, "router_lo_ipv6_address": faker.ipv6(), - "router_lo_iso_address": "TestAddress", + "router_lo_iso_address": iso_from_ipv4(mock_ipv4), } @@ -167,7 +170,7 @@ def test_import_router_endpoint_with_invalid_data(test_client, site_data, router assert response["detail"][1]["msg"] == "value is not a valid IPv6 address" -def test_import_iptrunk_successful_with_real_process(test_client, iptrunk_data, mock_routers): +def test_import_iptrunk_successful_with_real_process(test_client, mock_routers, iptrunk_data): response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) assert response.status_code == 201 @@ -182,7 +185,7 @@ def test_import_iptrunk_successful_with_real_process(test_client, iptrunk_data, @patch("gso.api.v1.imports._start_process") -def test_import_iptrunk_invalid_customer(mock_start_process, test_client, iptrunk_data, mock_routers): +def test_import_iptrunk_invalid_customer(mock_start_process, test_client, mock_routers, iptrunk_data): iptrunk_data["customer"] = "not_existing_customer" mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) @@ -203,29 +206,14 @@ def test_import_iptrunk_invalid_router_id_side_a_and_b(mock_start_process, test_ assert response.status_code == 422 assert response.json() == { "detail": [ - {"loc": ["body", "iptrunk_sideA_node_id"], "msg": "Router not found", "type": "value_error"}, - {"loc": ["body", "iptrunk_sideB_node_id"], "msg": "Router not found", "type": "value_error"}, - ] - } - - -@patch("gso.api.v1.imports._start_process") -def test_import_iptrunk_non_unique_members_side_a(mock_start_process, test_client, iptrunk_data, mock_routers): - mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" - - iptrunk_data["iptrunk_sideA_ae_members"] = [5, 5, 5, 5, 5] - iptrunk_data["iptrunk_sideB_ae_members"] = [4, 4, 4, 5, 5] - - response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) - - assert response.status_code == 422 - assert response.json() == { - "detail": [ - {"loc": ["body", "iptrunk_sideA_ae_members"], "msg": "Items must be unique", "type": "value_error"}, - {"loc": ["body", "iptrunk_sideB_ae_members"], "msg": "Items must be unique", "type": "value_error"}, { - "loc": ["body", "__root__"], - "msg": "Side A members should be at least 5 (iptrunk_minimum_links)", + "loc": ["body", "side_a_node_id"], + "msg": f"Router {iptrunk_data['side_a_node_id']} not found", + "type": "value_error", + }, + { + "loc": ["body", "side_b_node_id"], + "msg": f"Router {iptrunk_data['side_b_node_id']} not found", "type": "value_error", }, ] @@ -233,36 +221,37 @@ def test_import_iptrunk_non_unique_members_side_a(mock_start_process, test_clien @patch("gso.api.v1.imports._start_process") -def test_iptrunk_import_fails_on_side_a_member_count_mismatch( - mock_start_process, test_client, iptrunk_data, mock_routers -): +def test_import_iptrunk_non_unique_members_side_a(mock_start_process, test_client, mock_routers, iptrunk_data, faker): mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" - iptrunk_data["iptrunk_sideA_ae_members"].remove(iptrunk_data["iptrunk_sideA_ae_members"][0]) + repeat_interface_a = {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} + repeat_interface_b = {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} + iptrunk_data["side_a_ae_members"] = [repeat_interface_a for _ in range(5)] + iptrunk_data["side_b_ae_members"] = [repeat_interface_b for _ in range(5)] response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) assert response.status_code == 422 assert response.json() == { "detail": [ + {"loc": ["body", "side_a_ae_members"], "msg": "Items must be unique", "type": "value_error"}, + {"loc": ["body", "side_b_ae_members"], "msg": "Items must be unique", "type": "value_error"}, { "loc": ["body", "__root__"], "msg": "Side A members should be at least 5 (iptrunk_minimum_links)", "type": "value_error", - } + }, ] } @patch("gso.api.v1.imports._start_process") -def test_iptrunk_import_fails_on_side_a_member_description_mismatch( - mock_start_process, test_client, iptrunk_data, mock_routers +def test_import_iptrunk_fails_on_side_a_member_count_mismatch( + mock_start_process, test_client, mock_routers, iptrunk_data ): mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" - iptrunk_data["iptrunk_sideA_ae_members_descriptions"].remove( - iptrunk_data["iptrunk_sideA_ae_members_descriptions"][0] - ) + iptrunk_data["side_a_ae_members"].remove(iptrunk_data["side_a_ae_members"][0]) response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) @@ -271,7 +260,7 @@ def test_iptrunk_import_fails_on_side_a_member_description_mismatch( "detail": [ { "loc": ["body", "__root__"], - "msg": "Mismatch in Side A members and their descriptions", + "msg": "Side A members should be at least 5 (iptrunk_minimum_links)", "type": "value_error", } ] @@ -279,12 +268,12 @@ def test_iptrunk_import_fails_on_side_a_member_description_mismatch( @patch("gso.api.v1.imports._start_process") -def test_iptrunk_import_fails_on_side_a_and_b_members_mismatch( +def test_import_iptrunk_fails_on_side_a_and_b_members_mismatch( mock_start_process, test_client, iptrunk_data, mock_routers ): mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" - iptrunk_data["iptrunk_sideB_ae_members"].remove(iptrunk_data["iptrunk_sideB_ae_members"][0]) + iptrunk_data["side_b_ae_members"].remove(iptrunk_data["side_b_ae_members"][0]) response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) @@ -292,27 +281,3 @@ def test_iptrunk_import_fails_on_side_a_and_b_members_mismatch( assert response.json() == { "detail": [{"loc": ["body", "__root__"], "msg": "Mismatch between Side A and B members", "type": "value_error"}] } - - -@patch("gso.api.v1.imports._start_process") -def test_iptrunk_import_fails_on_side_b_member_description_mismatch( - mock_start_process, test_client, iptrunk_data, mock_routers -): - mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000" - - iptrunk_data["iptrunk_sideB_ae_members_descriptions"].remove( - iptrunk_data["iptrunk_sideB_ae_members_descriptions"][0] - ) - - response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data) - - assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "loc": ["body", "__root__"], - "msg": "Mismatch in Side B members and their descriptions", - "type": "value_error", - } - ] - } diff --git a/test/schemas/test_types.py b/test/schemas/test_types.py index dcfb515dbd315a92b205f4faa66a615f81ea0232..e5d757dbb84550df27f05949caf2e1ad78c7a2c8 100644 --- a/test/schemas/test_types.py +++ b/test/schemas/test_types.py @@ -1,6 +1,6 @@ import pytest -from gso.schemas.types import LatitudeCoordinate, LongitudeCoordinate +from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate @pytest.mark.parametrize( diff --git a/test/workflows/__init__.py b/test/workflows/__init__.py index 19707ff0ff1b4e53e3309f9c3509c8e24001a9fc..876b0e3e051d8f1f1fda3739dab8983b953dc1c2 100644 --- a/test/workflows/__init__.py +++ b/test/workflows/__init__.py @@ -160,14 +160,14 @@ def _sanitize_input(input_data: State | list[State]) -> list[State]: if not isinstance(input_data, list): input_data = [input_data] - # We need a copy here and we want to mimic the actual code that returns a serialized version of the state + # We need a copy here, and we want to mimic the actual code that returns a serialized version of the state return cast(list[State], json_loads(json_dumps(input_data))) def run_workflow(workflow_key: str, input_data: State | list[State]) -> tuple[WFProcess, ProcessStat, list]: # ATTENTION!! This code needs to be as similar as possible to `server.services.processes.start_process` - # The main differences are: we use a different step log function and we don't run in - # a sepperate thread + # The main differences are: we use a different step log function, and we don't run in + # a separate thread user_data = _sanitize_input(input_data) user = "john.doe" @@ -201,7 +201,7 @@ def run_workflow(workflow_key: str, input_data: State | list[State]) -> tuple[WF def resume_workflow( - process: ProcessStat, step_log: list[tuple[Step, WFProcess]], input_data: State + process: ProcessStat, step_log: list[tuple[Step, WFProcess]], input_data: State | list[State] ) -> tuple[WFProcess, list]: # ATTENTION!! This code needs to be as similar as possible to `server.services.processes.resume_process` # The main differences are: we use a different step log function, and we don't run in a separate thread @@ -294,3 +294,11 @@ def run_form_generator( result = stop.value return forms, result + + +def user_accept_and_assert_suspended(process_stat, step_log, extra_data=None): + extra_data = extra_data or {} + result, step_log = resume_workflow(process_stat, step_log, extra_data) + assert_suspended(result) + + return result, step_log diff --git a/test/workflows/conftest.py b/test/workflows/conftest.py index 6e6630890466c6a1f3d1d63000eefff387927472..a4b71a738da3818674500075dd7ce910e2c17382 100644 --- a/test/workflows/conftest.py +++ b/test/workflows/conftest.py @@ -1,7 +1,12 @@ import pytest from urllib3_mock import Responses -from test.fixtures import router_subscription_factory, site_subscription_factory # noqa +from test.fixtures import ( # noqa + iptrunk_side_subscription_factory, + iptrunk_subscription_factory, + router_subscription_factory, + site_subscription_factory, +) @pytest.fixture(autouse=True) diff --git a/gso/schemas/__init__.py b/test/workflows/iptrunk/__init__.py similarity index 100% rename from gso/schemas/__init__.py rename to test/workflows/iptrunk/__init__.py diff --git a/test/workflows/iptrunks/iptrunks/test_create_iptrunks.py b/test/workflows/iptrunk/test_create_iptrunk.py similarity index 78% rename from test/workflows/iptrunks/iptrunks/test_create_iptrunks.py rename to test/workflows/iptrunk/test_create_iptrunk.py index cf50c2ccbf1d342816372913be351dfe73fa3c82..a8f273f77cb87602fcef887599250f0c0eea153c 100644 --- a/test/workflows/iptrunks/iptrunks/test_create_iptrunks.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -3,13 +3,11 @@ from unittest.mock import patch import pytest -from gso.products import Iptrunk -from gso.products.product_blocks import PhyPortCapacity -from gso.products.product_blocks.iptrunk import IptrunkType -from gso.schemas.enums import ProductType -from gso.services.crm import get_customer_by_name +from gso.products import Iptrunk, ProductType +from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity +from gso.services.crm import customer_selector, get_customer_by_name from gso.services.subscriptions import get_product_id_by_name -from gso.workflows.utils import customer_selector +from gso.utils.helpers import LAGMember from test.workflows import ( assert_aborted, assert_complete, @@ -17,6 +15,7 @@ from test.workflows import ( extract_state, resume_workflow, run_workflow, + user_accept_and_assert_suspended, ) @@ -34,7 +33,7 @@ class MockedNetboxClient: def get_available_interfaces(self): interfaces = [] - for interface in range(1, 5): + for interface in range(5): interface_data = { "name": f"Interface{interface}", "module": {"display": f"Module{interface}"}, @@ -85,28 +84,31 @@ def input_form_wizard_data(router_subscription_factory, faker): router_side_b = router_subscription_factory() create_ip_trunk_step = { - "tt_number": faker.pystr(), + "tt_number": faker.tt_number(), "customer": getattr(customer_selector(), get_customer_by_name("GÉANT")["id"]), - "geant_s_sid": faker.pystr(), + "geant_s_sid": faker.geant_sid(), "iptrunk_type": IptrunkType.DARK_FIBER, "iptrunk_description": faker.sentence(), "iptrunk_speed": PhyPortCapacity.HUNDRED_GIGABIT_PER_SECOND, "iptrunk_minimum_links": 2, } - create_ip_trunk_side_a_router_name = {"iptrunk_sideA_node_id": router_side_a} + create_ip_trunk_side_a_router_name = {"side_a_node_id": router_side_a} create_ip_trunk_side_a_step = { - "iptrunk_sideA_ae_iface": "LAG1", - "iptrunk_sideA_ae_geant_a_sid": faker.pystr(), - "iptrunk_sideA_ae_members": ["Interface1", "Interface2"], - "iptrunk_sideA_ae_members_descriptions": ["Interface1 Description", "Interface2 Description"], + "side_a_ae_iface": "LAG1", + "side_a_ae_geant_a_sid": faker.geant_sid(), + "side_a_ae_members": [ + LAGMember(interface_name=f"Interface{interface}", interface_description=faker.sentence()) + for interface in range(5) + ], } - - create_ip_trunk_side_b_router_name = {"iptrunk_sideB_node_id": router_side_b} + create_ip_trunk_side_b_router_name = {"side_b_node_id": router_side_b} create_ip_trunk_side_b_step = { - "iptrunk_sideB_ae_iface": "LAG1", - "iptrunk_sideB_ae_geant_a_sid": faker.pystr(), - "iptrunk_sideB_ae_members": ["Interface1", "Interface2"], - "iptrunk_sideB_ae_members_descriptions": ["Interface1 Description", "Interface2 Description"], + "side_b_ae_iface": "LAG4", + "side_b_ae_geant_a_sid": faker.geant_sid(), + "side_b_ae_members": [ + LAGMember(interface_name=f"Interface{interface}", interface_description=faker.sentence()) + for interface in range(5) + ], } return [ @@ -118,14 +120,6 @@ def input_form_wizard_data(router_subscription_factory, faker): ] -def _user_accept_and_assert_suspended(process_stat, step_log, extra_data=None): - extra_data = extra_data or {} - result, step_log = resume_workflow(process_stat, step_log, extra_data) - assert_suspended(result) - - return result, step_log - - @pytest.mark.workflow @patch("gso.workflows.iptrunk.create_iptrunk.provisioning_proxy.check_ip_trunk") @patch("gso.workflows.iptrunk.create_iptrunk.provisioning_proxy.provision_ip_trunk") @@ -159,10 +153,10 @@ def test_successful_iptrunk_creation_with_standard_lso_result( "confirm": "ACCEPTED", } for _ in range(5): - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) assert_complete(result) @@ -213,10 +207,10 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( attempts = 3 for _ in range(0, attempts - 1): - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) assert_aborted(result) diff --git a/test/workflows/iptrunk/test_modify_isis_metric.py b/test/workflows/iptrunk/test_modify_isis_metric.py new file mode 100644 index 0000000000000000000000000000000000000000..521d6d2739f821b1d923aea06966736cb4722a4e --- /dev/null +++ b/test/workflows/iptrunk/test_modify_isis_metric.py @@ -0,0 +1,58 @@ +from unittest.mock import patch + +import pytest + +from gso.products import Iptrunk +from test.workflows import ( + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, + user_accept_and_assert_suspended, +) + + +@pytest.mark.workflow +@patch("gso.workflows.iptrunk.modify_isis_metric.provisioning_proxy.provision_ip_trunk") +def test_iptrunk_modify_isis_metric_success( + mock_provision_ip_trunk, + iptrunk_subscription_factory, + faker, +): + # Set up mock return values + product_id = iptrunk_subscription_factory() + new_isis_metric = faker.pyint() + + # Run workflow + initial_iptrunk_data = [ + {"subscription_id": product_id}, + {"tt_number": faker.tt_number(), "isis_metric": new_isis_metric}, + ] + result, process_stat, step_log = run_workflow("modify_isis_metric", initial_iptrunk_data) + assert_suspended(result) + + lso_return = { + "pp_run_results": { + "status": "ok", + "job_id": faker.uuid4(), + "output": "parsed_output", + "return_code": 0, + }, + "confirm": "ACCEPTED", + } + + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Iptrunk.from_subscription(subscription_id) + + assert "active" == subscription.status + assert mock_provision_ip_trunk.call_count == 2 + assert subscription.iptrunk.iptrunk_isis_metric == new_isis_metric diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..8f39c4d7f784be6c3beea80c3741f7d856271c20 --- /dev/null +++ b/test/workflows/iptrunk/test_modify_trunk_interface.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + +import pytest + +from gso.products import Iptrunk +from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity +from test.workflows import ( + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, + user_accept_and_assert_suspended, +) + + +@pytest.mark.workflow +@patch("gso.workflows.iptrunk.modify_trunk_interface.provisioning_proxy.provision_ip_trunk") +def test_iptrunk_modify_trunk_interface_success( + mock_provision_ip_trunk, + iptrunk_subscription_factory, + faker, +): + # Set up mock return values + product_id = iptrunk_subscription_factory() + new_sid = faker.geant_sid() + new_description = faker.sentence() + new_type = IptrunkType.LEASED + new_speed = PhyPortCapacity.FOUR_HUNDRED_GIGABIT_PER_SECOND + new_link_count = 2 + + new_side_a_sid = faker.geant_sid() + new_side_a_ae_members = [ + {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5) + ] + + new_side_b_sid = faker.geant_sid() + new_side_b_ae_members = [ + {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5) + ] + + # Run workflow + initial_iptrunk_data = [ + {"subscription_id": product_id}, + { + "tt_number": faker.tt_number(), + "geant_s_sid": new_sid, + "iptrunk_description": new_description, + "iptrunk_type": new_type, + "iptrunk_speed": new_speed, + "iptrunk_minimum_links": new_link_count, + }, + { + "side_a_ae_geant_a_sid": new_side_a_sid, + "side_a_ae_members": new_side_a_ae_members, + }, + { + "side_b_ae_geant_a_sid": new_side_b_sid, + "side_b_ae_members": new_side_b_ae_members, + }, + ] + + result, process_stat, step_log = run_workflow("modify_trunk_interface", initial_iptrunk_data) + assert_suspended(result) + + lso_return = { + "pp_run_results": { + "status": "ok", + "job_id": faker.uuid4(), + "output": "parsed_output", + "return_code": 0, + }, + "confirm": "ACCEPTED", + } + + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Iptrunk.from_subscription(subscription_id) + + assert "active" == subscription.status + assert mock_provision_ip_trunk.call_count == 2 + + # Assert all subscription properties have been updated correctly + assert subscription.description == f"IP trunk, geant_s_sid:{new_sid}" + assert subscription.iptrunk.geant_s_sid == new_sid + assert subscription.iptrunk.iptrunk_description == new_description + assert subscription.iptrunk.iptrunk_type == new_type + assert subscription.iptrunk.iptrunk_speed == new_speed + assert subscription.iptrunk.iptrunk_minimum_links == new_link_count + assert subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid == new_side_a_sid + + def _find_interface_by_name(interfaces: list[dict[str, str]], name: str): + for interface in interfaces: + if interface["interface_name"] == name: + return interface + raise IndexError(f"Interface {name} not found!") + + for member in subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members: + assert ( + member.interface_description + == _find_interface_by_name(new_side_a_ae_members, member.interface_name)["interface_description"] + ) + + assert subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_geant_a_sid == new_side_b_sid + + for member in subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members: + assert ( + member.interface_description + == _find_interface_by_name(new_side_b_ae_members, member.interface_name)["interface_description"] + ) diff --git a/test/workflows/iptrunk/test_terminate_iptrunk.py b/test/workflows/iptrunk/test_terminate_iptrunk.py new file mode 100644 index 0000000000000000000000000000000000000000..2234f29f643d39268b483d742b8e3be4822f0dca --- /dev/null +++ b/test/workflows/iptrunk/test_terminate_iptrunk.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +import pytest + +from gso.products import Iptrunk +from test.workflows import ( + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, + user_accept_and_assert_suspended, +) + + +@pytest.mark.workflow +@patch("gso.workflows.iptrunk.terminate_iptrunk.provisioning_proxy.provision_ip_trunk") +@patch("gso.workflows.iptrunk.terminate_iptrunk.provisioning_proxy.deprovision_ip_trunk") +@patch("gso.workflows.iptrunk.terminate_iptrunk.infoblox.delete_network") +def test_iptrunk_modify_isis_metric_success( + mock_infoblox_delete_network, + mock_deprovision_ip_trunk, + mock_provision_ip_trunk, + iptrunk_subscription_factory, + faker, +): + # Set up mock return values + product_id = iptrunk_subscription_factory() + + # Run workflow + initial_iptrunk_data = [ + {"subscription_id": product_id}, + {"tt_number": faker.tt_number(), "remove_configuration": True, "clean_up_ipam": True}, + ] + result, process_stat, step_log = run_workflow("terminate_iptrunk", initial_iptrunk_data) + assert_suspended(result) + + lso_return = { + "pp_run_results": { + "status": "ok", + "job_id": faker.uuid4(), + "output": "parsed_output", + "return_code": 0, + }, + "confirm": "ACCEPTED", + } + + for _ in range(2): + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Iptrunk.from_subscription(subscription_id) + + assert "terminated" == subscription.status + assert mock_provision_ip_trunk.call_count == 1 + assert mock_deprovision_ip_trunk.call_count == 2 + assert mock_infoblox_delete_network.call_count == 2 + assert subscription.iptrunk.iptrunk_isis_metric == 90000 diff --git a/test/workflows/router/__init__.py b/test/workflows/router/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test/workflows/router/test_create_router.py b/test/workflows/router/test_create_router.py new file mode 100644 index 0000000000000000000000000000000000000000..5547dc6571bf0979e22920f67c9a0fa375d5a1da --- /dev/null +++ b/test/workflows/router/test_create_router.py @@ -0,0 +1,228 @@ +from unittest.mock import patch + +import pytest +from infoblox_client import objects + +from gso.products import ProductType, Site +from gso.products.product_blocks.router import RouterRole, RouterVendor +from gso.products.product_types.router import Router +from gso.services.crm import customer_selector, get_customer_by_name +from gso.services.subscriptions import get_product_id_by_name +from test.workflows import ( + assert_aborted, + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, + user_accept_and_assert_suspended, +) + + +@pytest.fixture +def router_creation_input_form_data(site_subscription_factory, faker): + router_site = site_subscription_factory() + + return { + "tt_number": faker.tt_number(), + "customer": getattr(customer_selector(), get_customer_by_name("GÉANT")["id"]), + "router_site": router_site, + "hostname": faker.pystr(), + "ts_port": faker.pyint(), + "router_vendor": RouterVendor.NOKIA, + "router_role": faker.random_choices(elements=(RouterRole.P, RouterRole.PE, RouterRole.AMT), length=1)[0], + "is_ias_connected": True, + } + + +@pytest.mark.workflow +@patch("gso.workflows.router.create_router.provisioning_proxy.provision_router") +@patch("gso.workflows.router.create_router.NetboxClient.create_device") +@patch("gso.workflows.router.create_router.infoblox.hostname_available") +@patch("gso.workflows.router.create_router.infoblox.find_network_by_cidr") +@patch("gso.workflows.router.create_router.infoblox.find_host_by_fqdn") +@patch("gso.workflows.router.create_router.infoblox.allocate_v6_network") +@patch("gso.workflows.router.create_router.infoblox.allocate_v4_network") +@patch("gso.workflows.router.create_router.infoblox.allocate_host") +def test_create_router_success( + mock_allocate_host, + mock_allocate_v4_network, + mock_allocate_v6_network, + mock_find_host_by_fqdn, + mock_find_network_by_cidr, + mock_hostname_available, + mock_netbox_create_device, + mock_provision_router, + router_creation_input_form_data, + faker, +): + # Set up mock return values + product_id = get_product_id_by_name(ProductType.ROUTER) + mock_site = Site.from_subscription(router_creation_input_form_data["router_site"]).site + mock_v4 = faker.ipv4() + mock_v4_net = faker.ipv4_network() + mock_v6 = faker.ipv6() + mock_fqdn = ( + f"{router_creation_input_form_data['hostname']}.{mock_site.site_name.lower()}." + f"{mock_site.site_country_code.lower()}.geant.net" + ) + mock_hostname_available.return_value = True + mock_allocate_host.return_value = str(mock_v4), str(mock_v6) + mock_allocate_v4_network.return_value = mock_v4_net + mock_allocate_v6_network.return_value = faker.ipv6_network() + + # Run workflow + initial_router_data = [{"product": product_id}, router_creation_input_form_data] + result, process_stat, step_log = run_workflow("create_router", initial_router_data) + assert_suspended(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + mock_find_host_by_fqdn.return_value = objects.HostRecord( + connector=None, + aliases=[mock_fqdn], + comment=subscription_id, + ipv4addrs=[ + objects.IPv4( + ipv4addr=str(mock_v4), + configure_for_dhcp=False, + mac="00:00:00:00:00:00", + ip=str(mock_v4), + host=f"lo0.{mock_fqdn}", + ) + ], + name=mock_fqdn, + ) + mock_find_network_by_cidr.return_value = objects.NetworkV4( + connector=None, + comment=subscription_id, + network=str(mock_v4_net), + network_view="default", + cidr=str(mock_v4_net), + ) + + lso_return = { + "pp_run_results": { + "status": "ok", + "job_id": faker.uuid4(), + "output": "parsed_output", + "return_code": 0, + }, + "confirm": "ACCEPTED", + } + + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) + + assert_complete(result) + + state = extract_state(result) + subscription = Router.from_subscription(subscription_id) + + assert "active" == subscription.status + assert subscription.description == f"Router {mock_fqdn}" + + assert mock_provision_router.call_count == 2 + assert mock_netbox_create_device.call_count == 1 + assert mock_find_host_by_fqdn.call_count == 1 + assert mock_find_network_by_cidr.call_count == 3 + for error in ["ipam_warning", "ipam_si_warning", "ipam_ias_lt_ipv4_warning", "ipam_ias_lt_ipv6_warning"]: + assert error not in state + + +@pytest.mark.workflow +@patch("gso.workflows.router.create_router.provisioning_proxy.provision_router") +@patch("gso.workflows.router.create_router.NetboxClient.create_device") +@patch("gso.workflows.router.create_router.infoblox.hostname_available") +@patch("gso.workflows.router.create_router.infoblox.find_network_by_cidr") +@patch("gso.workflows.router.create_router.infoblox.find_host_by_fqdn") +@patch("gso.workflows.router.create_router.infoblox.allocate_v6_network") +@patch("gso.workflows.router.create_router.infoblox.allocate_v4_network") +@patch("gso.workflows.router.create_router.infoblox.allocate_host") +def test_create_router_lso_failure( + mock_allocate_host, + mock_allocate_v4_network, + mock_allocate_v6_network, + mock_find_host_by_fqdn, + mock_find_network_by_cidr, + mock_hostname_available, + mock_netbox_create_device, + mock_provision_router, + router_creation_input_form_data, + faker, +): + # Set up mock return values + mock_site = Site.from_subscription(router_creation_input_form_data["router_site"]).site + mock_v4 = faker.ipv4() + mock_v4_net = faker.ipv4_network() + mock_v6 = faker.ipv6() + mock_fqdn = ( + f"{router_creation_input_form_data['hostname']}.{mock_site.site_name.lower()}." + f"{mock_site.site_country_code.lower()}.geant.net" + ) + mock_hostname_available.return_value = True + mock_allocate_host.return_value = str(mock_v4), str(mock_v6) + mock_allocate_v4_network.return_value = mock_v4_net + mock_allocate_v6_network.return_value = faker.ipv6_network() + mock_find_host_by_fqdn.return_value = objects.HostRecord( + connector=None, + aliases=[mock_fqdn], + comment=faker.sentence(), + ipv4addrs=[ + objects.IPv4( + ipv4addr=str(mock_v4), + configure_for_dhcp=False, + mac="00:00:00:00:00:00", + ip=str(mock_v4), + host=f"lo0.{mock_fqdn}", + ) + ], + name=mock_fqdn, + ) + mock_find_network_by_cidr.return_value = objects.NetworkV4( + connector=None, + comment=faker.sentence(), + network=str(mock_v4_net), + network_view="default", + cidr=str(mock_v4_net), + ) + + # Run workflow + product_id = get_product_id_by_name(ProductType.ROUTER) + initial_router_data = [{"product": product_id}, router_creation_input_form_data] + result, process_stat, step_log = run_workflow("create_router", initial_router_data) + assert_suspended(result) + + lso_return = { + "pp_run_results": { + "status": "failure", + "job_id": faker.uuid4(), + "output": "parsed_output", + "return_code": 1, + }, + "confirm": "ACCEPTED", + } + + attempts = 3 + for _ in range(attempts - 1): + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) + + result, step_log = user_accept_and_assert_suspended(process_stat, step_log, lso_return) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) + + assert_aborted(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Router.from_subscription(subscription_id) + + assert "provisioning" == subscription.status + assert subscription.description == f"Router {mock_fqdn}" + + assert mock_provision_router.call_count == attempts + assert mock_netbox_create_device.call_count == 0 + assert mock_find_host_by_fqdn.call_count == 0 + assert mock_find_network_by_cidr.call_count == 0 diff --git a/test/workflows/router/test_terminate_router.py b/test/workflows/router/test_terminate_router.py new file mode 100644 index 0000000000000000000000000000000000000000..e580316ba86df856c9bb726c37cd70ac34f95436 --- /dev/null +++ b/test/workflows/router/test_terminate_router.py @@ -0,0 +1,41 @@ +from unittest.mock import patch + +import pytest + +from gso.products import Router +from test.workflows import assert_complete, extract_state, run_workflow + + +@pytest.fixture +def router_termination_input_form_data(site_subscription_factory, faker): + return {"tt_number": faker.tt_number(), "remove_configuration": True, "clean_up_ipam": True} + + +@pytest.mark.workflow +@patch("gso.workflows.router.terminate_router.NetboxClient.delete_device") +@patch("gso.workflows.router.terminate_router.infoblox.delete_host_by_ip") +@patch("gso.workflows.router.terminate_router.infoblox.delete_network") +def test_terminate_router_success( + mock_delete_network, + mock_delete_host_by_ip, + mock_delete_device, + router_termination_input_form_data, + router_subscription_factory, + faker, +): + # Set up active subscription in database + product_id = router_subscription_factory() + + # Run workflow + initial_router_data = [{"subscription_id": product_id}, router_termination_input_form_data] + result, process_stat, step_log = run_workflow("terminate_router", initial_router_data) + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Router.from_subscription(subscription_id) + + assert "terminated" == subscription.status + assert mock_delete_network.call_count == 3 + assert mock_delete_device.call_count == 1 + assert mock_delete_host_by_ip.call_count == 1 diff --git a/test/workflows/site/test_create_site.py b/test/workflows/site/test_create_site.py index da25a2b09f0e3183b3f85f69c75f4fe7c146f2ce..20486ec23bc169bede53bd6668de2b14c4761d28 100644 --- a/test/workflows/site/test_create_site.py +++ b/test/workflows/site/test_create_site.py @@ -1,8 +1,8 @@ import pytest +from gso.products import ProductType from gso.products.product_blocks.site import SiteTier from gso.products.product_types.site import Site -from gso.schemas.enums import ProductType from gso.services.crm import get_customer_by_name from gso.services.subscriptions import get_product_id_by_name from test.workflows import assert_complete, extract_state, run_workflow diff --git a/tox.ini b/tox.ini index 09bbbd4b7dc3d796a57232be610f66876eb46c99..f79b0ea3fca0500ac30d3c9cd9f21a04d453b645 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,9 @@ select = B,C,D,E,F,G,I,N,S,T,W,B902,B903,R max-line-length = 120 ban-relative-imports = true +[pytest] +markers = workflow + [testenv] passenv = DATABASE_URI_TEST,SKIP_ALL_TESTS deps = @@ -26,8 +29,7 @@ commands = mypy . flake8 coverage erase - coverage run --source gso -m pytest {posargs} + coverage run --source gso --omit="gso/migrations/*" -m pytest {posargs} coverage xml coverage html - # coverage report --fail-under 80 - coverage report + coverage report --fail-under 80