diff --git a/Changelog.md b/Changelog.md index 8b2cbccbab074be1a7047c2eebf63a554b04d895..a43c10d48d18746ec55c814297e676a300e9e7cf 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. + +## [1.0] - 2024-03-28 +- PHASE 1 initial release + ## [0.9] - 2024-03-20 - `migrate_iptrunk` workflow includes Ansible trunk checks. - `create_iptrunk` and `migrate_iptrunk` now update IPAM / DNS. diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 432f73de9e8cdd7f7fd38c5e99f9b434fb1fd82c..4844b8049b2fff9c98561ac6e9f56b92a63ef4ae 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -3,6 +3,9 @@ Glossary of terms .. glossary:: + AAI + Authentication and Authorisation Infrastructure + API Application Programming Interface @@ -23,9 +26,15 @@ Glossary of terms CRUD Create, Read, Update, Delete + DNS + Domain Name System + FQDN Fully Quantified Domain Name + GAP + The GÉANT Automation Platform + GSO GÉANT Service Orchestrator @@ -42,6 +51,9 @@ Glossary of terms ISO International Organisation for Standardisation + JSON + JavaScript Object Notation + LAG Link Aggregation: a bundle of multiple network connections. @@ -69,6 +81,3 @@ Glossary of terms WFO `Workflow Orchestrator <https://workfloworchestrator.org/>`_ - - AAI - Authentication and Authorisation Infrastructure diff --git a/docs/source/module/workflows/router/modify_connection_strategy.rst b/docs/source/module/workflows/router/modify_connection_strategy.rst new file mode 100644 index 0000000000000000000000000000000000000000..b60db9b2fcee862a351408d2d66a693c3fc61024 --- /dev/null +++ b/docs/source/module/workflows/router/modify_connection_strategy.rst @@ -0,0 +1,6 @@ +``gso.workflows.router.modify_connection_strategy`` +========================================= + +.. automodule:: gso.workflows.router.modify_connection_strategy + :members: + :show-inheritance: diff --git a/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt b/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt index 2b2d2a4f8b71a93a3dbce06687222161e1d1db01..af07d476da370b4fda5d5f2902f784731ae6d5d4 100644 --- a/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt +++ b/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt @@ -1,7 +1,12 @@ GÉANT Automation Platform +G[ÉE]ANT [GSO|gso] +(GSO|gso) +N(okia|OKIA) +IMS Vereniging -[[T|t]erminate|TERMINATE] +[T|t]erminate +TERMINATED? WFO Ansible [Dd]eprovision @@ -9,12 +14,15 @@ API DNS dry_run Dark_fiber -[A|a]ddress -[I|i]ptrunk -[A|a]llocate +[Aa]ddress +[Ii]ptrunk +[Aa]llocate PHASE 1 [Mm]odify +FQDN AAI -[M|m]iddleware +[Mm]iddleware TWAMP Pydantic +UUID +SNMP diff --git a/gso/api/v1/subscriptions.py b/gso/api/v1/subscriptions.py index 9cc6075ac8bb4858e0354afb691b645a0bdcc1dd..bf4e96bfb6c0b2932d183d1c2707f2a18bf5a608 100644 --- a/gso/api/v1/subscriptions.py +++ b/gso/api/v1/subscriptions.py @@ -2,14 +2,16 @@ from typing import Any -from fastapi import Depends, status +from fastapi import Depends, Response, status from fastapi.routing import APIRouter from orchestrator.domain import SubscriptionModel from orchestrator.schemas import SubscriptionDomainModelSchema from orchestrator.services.subscriptions import build_extended_domain_model +from orchestrator.types import SubscriptionLifecycle from gso.auth.api_key_auth import get_api_key -from gso.services.subscriptions import get_active_router_subscriptions +from gso.products import ProductType +from gso.services.subscriptions import get_router_subscriptions, get_subscriptions router = APIRouter( prefix="/subscriptions", @@ -24,11 +26,44 @@ router = APIRouter( response_model=list[SubscriptionDomainModelSchema], ) def subscription_routers() -> list[dict[str, Any]]: - """Retrieve all active router subscriptions.""" + """Retrieve all active or provisioning router subscriptions.""" subscriptions = [] - for r in get_active_router_subscriptions(): + routers = get_router_subscriptions(lifecycles=[SubscriptionLifecycle.ACTIVE, SubscriptionLifecycle.PROVISIONING]) + for r in routers: subscription = SubscriptionModel.from_subscription(r["subscription_id"]) extended_model = build_extended_domain_model(subscription) subscriptions.append(extended_model) return subscriptions + + +@router.get( + "/dashboard_devices", + status_code=status.HTTP_200_OK, + response_class=Response, + responses={ + 200: { + "content": {"text/plain": {}}, + "description": "Return a flat file of FQDNs.", + } + }, +) +def subscription_dashboard_devices() -> Response: + """Retrieve FQDN for all dashboard devices that are monitored.""" + fqdns = [] + dashboard_devices = get_subscriptions( + product_types=[ProductType.ROUTER, ProductType.SUPER_POP_SWITCH, ProductType.OFFICE_ROUTER], + lifecycles=[SubscriptionLifecycle.ACTIVE, SubscriptionLifecycle.PROVISIONING], + ) + for device in dashboard_devices: + subscription = SubscriptionModel.from_subscription(device["subscription_id"]) + extended_model = build_extended_domain_model(subscription) + if extended_model["product"]["product_type"] == ProductType.ROUTER: + fqdns.append(extended_model["router"]["router_fqdn"]) + elif extended_model["product"]["product_type"] == ProductType.SUPER_POP_SWITCH: + fqdns.append(extended_model["super_pop_switch"]["super_pop_switch_fqdn"]) + elif extended_model["product"]["product_type"] == ProductType.OFFICE_ROUTER: + fqdns.append(extended_model["office_router"]["office_router_fqdn"]) + + fqdn_flat_file = "\n".join(fqdns) + return Response(content=fqdn_flat_file, media_type="text/plain") diff --git a/gso/db/models.py b/gso/db/models.py index 350aa9072198c5e4adfb903f213aa681f19aca81..02d8c59ce72e6e1ecb47bab5c513853a9f59551b 100644 --- a/gso/db/models.py +++ b/gso/db/models.py @@ -33,18 +33,19 @@ class PartnerTable(BaseModel): __tablename__ = "partners" partner_id = mapped_column(String, server_default=text("uuid_generate_v4"), primary_key=True) - name = mapped_column(String, unique=True) - email = mapped_column(String, unique=True, nullable=True) + name = mapped_column(String, unique=True, nullable=True) + email = mapped_column(String, unique=True, nullable=False) + partner_type = mapped_column(Enum(PartnerType), nullable=False) + as_number = mapped_column( - String, unique=True + String, unique=True, nullable=True ) # the as_number and as_set are mutually exclusive. if you give me one I don't need the other - as_set = mapped_column(String) + as_set = mapped_column(String, nullable=True) route_set = mapped_column(String, nullable=True) black_listed_as_sets = mapped_column(ARRAY(String), nullable=True) additional_routers = mapped_column(ARRAY(String), nullable=True) additional_bgp_speakers = mapped_column(ARRAY(String), nullable=True) - partner_type = mapped_column(Enum(PartnerType), nullable=False) created_at = mapped_column(UtcTimestamp, server_default=text("current_timestamp"), nullable=False) updated_at = mapped_column( UtcTimestamp, server_default=text("current_timestamp"), nullable=False, onupdate=text("current_timestamp") diff --git a/gso/migrations/versions/2023-11-21_e8378fbcfbf3_add_initial_products.py b/gso/migrations/versions/2023-11-21_e8378fbcfbf3_add_initial_products.py index 12c51d34d2aba2e4eb5d569f361e9e1c25409c43..d0c1a38893f1355a597a121cd39d553b6a847325 100644 --- a/gso/migrations/versions/2023-11-21_e8378fbcfbf3_add_initial_products.py +++ b/gso/migrations/versions/2023-11-21_e8378fbcfbf3_add_initial_products.py @@ -11,7 +11,7 @@ from alembic import op # revision identifiers, used by Alembic. revision = 'e8378fbcfbf3' down_revision = 'da5c9f4cce1c' -branch_labels = ('data',) +branch_labels = None depends_on = None diff --git a/gso/migrations/versions/2024-03-20_d61c0f92da1e_edit_partner_table_making_some_fields_.py b/gso/migrations/versions/2024-03-20_d61c0f92da1e_edit_partner_table_making_some_fields_.py new file mode 100644 index 0000000000000000000000000000000000000000..8449597fb5eec689e1a840a1ab700f22bf121640 --- /dev/null +++ b/gso/migrations/versions/2024-03-20_d61c0f92da1e_edit_partner_table_making_some_fields_.py @@ -0,0 +1,30 @@ +"""Edit Partner table, Making some fields required. + +Revision ID: d61c0f92da1e +Revises: eaed66b04913 +Create Date: 2024-03-20 12:29:24.145489 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'd61c0f92da1e' +down_revision = 'eaed66b04913' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text( + """UPDATE partners SET email = 'goat@geant.org' WHERE name='GEANT'""")) + + op.alter_column('partners', 'email', existing_type=sa.String(), nullable=False) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('partners', 'email', existing_type=sa.String(), nullable=True) + # ### end Alembic commands ### diff --git a/gso/migrations/versions/2024-03-21_734e36a3e70b_add_subscription_cancellation_workflow.py b/gso/migrations/versions/2024-03-21_734e36a3e70b_add_subscription_cancellation_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..195995bac5923f770effa966475a7159b0707998 --- /dev/null +++ b/gso/migrations/versions/2024-03-21_734e36a3e70b_add_subscription_cancellation_workflow.py @@ -0,0 +1,45 @@ +"""Add subscription cancellation workflow. + +Revision ID: 734e36a3e70b +Revises: d61c0f92da1e +Create Date: 2024-03-21 13:03:08.981028 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '734e36a3e70b' +down_revision = 'a2cd3f2e6d7a' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import add_products_to_workflow_by_product_tag, create_workflow, delete_workflow, remove_products_from_workflow_by_product_tag + + +products = ["RTR", "IPTRUNK"] +new_workflows = [ + { + "name": "cancel_subscription", + "target": "TERMINATE", + "description": "Cancel a subscription", + "product_type": "Site" + } +] + + +def upgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + create_workflow(conn, workflow) + for product_tag in products: + add_products_to_workflow_by_product_tag(conn, "cancel_subscription", product_tag) + + +def downgrade() -> None: + conn = op.get_bind() + for product_tag in products: + remove_products_from_workflow_by_product_tag(conn, "cancel_subscription", product_tag) + for workflow in new_workflows: + delete_workflow(conn, workflow["name"]) diff --git a/gso/migrations/versions/2024-03-21_a2cd3f2e6d7a_modify_connection_streategy_workflow.py b/gso/migrations/versions/2024-03-21_a2cd3f2e6d7a_modify_connection_streategy_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..d15621b846374f2f5a2846f7ccb48e36a49723a0 --- /dev/null +++ b/gso/migrations/versions/2024-03-21_a2cd3f2e6d7a_modify_connection_streategy_workflow.py @@ -0,0 +1,39 @@ +"""Modify connection strategy workflow. + +Revision ID: a2cd3f2e6d7a +Revises: +Create Date: 2024-03-21 16:05:59.043106 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'a2cd3f2e6d7a' +down_revision = 'd61c0f92da1e' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import create_workflow, delete_workflow + +new_workflows = [ + { + "name": "modify_connection_strategy", + "target": "MODIFY", + "description": "Modify connection strategy", + "product_type": "Router" + } +] + + +def upgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + create_workflow(conn, workflow) + + +def downgrade() -> None: + conn = op.get_bind() + for workflow in new_workflows: + delete_workflow(conn, workflow["name"]) diff --git a/gso/migrations/versions/2024-03-27_4ec89ab289c0_remove_subscription_cancellation_.py b/gso/migrations/versions/2024-03-27_4ec89ab289c0_remove_subscription_cancellation_.py new file mode 100644 index 0000000000000000000000000000000000000000..d9ee38563c2eed43f0012b504af4d81af8d4d07c --- /dev/null +++ b/gso/migrations/versions/2024-03-27_4ec89ab289c0_remove_subscription_cancellation_.py @@ -0,0 +1,51 @@ +"""remove subscription cancellation workflow. + +Revision ID: 4ec89ab289c0 +Revises: +Create Date: 2024-03-27 10:21:08.539591 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '4ec89ab289c0' +down_revision = '734e36a3e70b' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import create_workflow, delete_workflow + +old_workflows = [ + { + "name": "cancel_subscription", + "target": "TERMINATE", + "description": "Cancel a subscription", + "product_type": "Iptrunk" + }, + { + "name": "cancel_subscription", + "target": "TERMINATE", + "description": "Cancel a subscription", + "product_type": "Router" + }, + { + "name": "cancel_subscription", + "target": "TERMINATE", + "description": "Cancel a subscription", + "product_type": "Site" + } +] + + +def upgrade() -> None: + conn = op.get_bind() + for workflow in old_workflows: + delete_workflow(conn, workflow["name"]) + + +def downgrade() -> None: + conn = op.get_bind() + for workflow in old_workflows: + create_workflow(conn, workflow) diff --git a/gso/products/__init__.py b/gso/products/__init__.py index 2fd25d9fe556299706f618213d06a9ae9f04763e..32350d65cc3a9b0e92e4757ffc8a7faf632f7379 100644 --- a/gso/products/__init__.py +++ b/gso/products/__init__.py @@ -15,8 +15,8 @@ from gso.products.product_types.site import Site from gso.products.product_types.super_pop_switch import SuperPopSwitch -class ProductType(strEnum): - """An enumerator of available products in :term:`GSO`.""" +class ProductName(strEnum): + """An enumerator of available product names in :term:`GSO`.""" IP_TRUNK = "IP trunk" ROUTER = "Router" @@ -25,12 +25,22 @@ class ProductType(strEnum): OFFICE_ROUTER = "Office router" +class ProductType(strEnum): + """An enumerator of available product types in :term:`GSO`.""" + + IP_TRUNK = Iptrunk.__name__ + ROUTER = Router.__name__ + SITE = Site.__name__ + SUPER_POP_SWITCH = SuperPopSwitch.__name__ + OFFICE_ROUTER = OfficeRouter.__name__ + + SUBSCRIPTION_MODEL_REGISTRY.update( { - "IP trunk": Iptrunk, - "Router": Router, - "Site": Site, - "Super PoP switch": SuperPopSwitch, - "Office router": OfficeRouter, + ProductName.IP_TRUNK.value: Iptrunk, + ProductName.ROUTER.value: Router, + ProductName.SITE.value: Site, + ProductName.SUPER_POP_SWITCH.value: SuperPopSwitch, + ProductName.OFFICE_ROUTER.value: OfficeRouter, }, ) diff --git a/gso/services/subscriptions.py b/gso/services/subscriptions.py index e89d9c25ad1cc52f68ae6ac3aea75a0542b92559..8d9900a39049275509b82e94043018a048d122ff 100644 --- a/gso/services/subscriptions.py +++ b/gso/services/subscriptions.py @@ -19,28 +19,31 @@ from orchestrator.services.subscriptions import query_in_use_by_subscriptions from orchestrator.types import SubscriptionLifecycle from pydantic_forms.types import UUIDstr -from gso.products import ProductType +from gso.products import ProductName, ProductType from gso.products.product_types.site import Site SubscriptionType = dict[str, Any] def get_subscriptions( - product_type: str, - lifecycle: SubscriptionLifecycle, + product_types: list[ProductType], + lifecycles: list[SubscriptionLifecycle] | None = None, includes: list[str] | None = None, excludes: list[str] | None = None, ) -> list[SubscriptionType]: """Retrieve active subscriptions for a specific product type. - :param str product_type: The type of the product for which to retrieve subscriptions. - :param SubscriptionLifecycle lifecycle: The lifecycle that the products must be in. + :param list[ProductName] product_types: The types of the product for which to retrieve subscriptions. + :param SubscriptionLifecycle lifecycles: The lifecycles that the products must be in. :param list[str] includes: List of fields to be included in the returned Subscription objects. :param list[str] excludes: List of fields to be excluded from the returned Subscription objects. :return: A list of Subscription objects that match the query. :rtype: list[Subscription] """ + if not lifecycles: + lifecycles = list(SubscriptionLifecycle) + if not includes: includes = [col.name for col in SubscriptionTable.__table__.columns] @@ -50,8 +53,8 @@ def get_subscriptions( dynamic_fields = [getattr(SubscriptionTable, field) for field in includes] query = SubscriptionTable.query.join(ProductTable).filter( - ProductTable.product_type == product_type, - SubscriptionTable.status == lifecycle, + ProductTable.product_type.in_([str(product_type) for product_type in product_types]), + SubscriptionTable.status.in_([str(lifecycle) for lifecycle in lifecycles]), ) results = query.with_entities(*dynamic_fields).all() @@ -68,7 +71,23 @@ def get_active_site_subscriptions(includes: list[str] | None = None) -> list[Sub :return: A list of Subscription objects for sites. :rtype: list[Subscription] """ - return get_subscriptions(product_type=ProductType.SITE, lifecycle=SubscriptionLifecycle.ACTIVE, includes=includes) + return get_subscriptions( + product_types=[ProductType.SITE], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=includes + ) + + +def get_router_subscriptions( + includes: list[str] | None = None, lifecycles: list[SubscriptionLifecycle] | None = None +) -> list[SubscriptionType]: + """Retrieve subscriptions specifically for routers. + + :param includes: The fields to be included in the returned Subscription objects. + :type includes: list[str] + + :return: A list of Subscription objects for routers. + :rtype: list[Subscription] + """ + return get_subscriptions(product_types=[ProductType.ROUTER], lifecycles=lifecycles, includes=includes) def get_active_router_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: @@ -80,7 +99,9 @@ def get_active_router_subscriptions(includes: list[str] | None = None) -> list[S :return: A list of Subscription objects for routers. :rtype: list[Subscription] """ - return get_subscriptions(product_type="Router", lifecycle=SubscriptionLifecycle.ACTIVE, includes=includes) + return get_subscriptions( + product_types=[ProductType.ROUTER], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=includes + ) def get_provisioning_router_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: @@ -89,7 +110,9 @@ def get_provisioning_router_subscriptions(includes: list[str] | None = None) -> :param list[str] includes: The fields to be included in the returned Subscription objects. :return list[Subscription]: A list of router Subscription objects. """ - return get_subscriptions(product_type="Router", lifecycle=SubscriptionLifecycle.PROVISIONING, includes=includes) + return get_subscriptions( + product_types=[ProductType.ROUTER], lifecycles=[SubscriptionLifecycle.PROVISIONING], includes=includes + ) def get_active_iptrunk_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: @@ -101,17 +124,22 @@ def get_active_iptrunk_subscriptions(includes: list[str] | None = None) -> list[ :return: A list of Subscription objects for IP trunks. :rtype: list[Subscription] """ - return get_subscriptions(product_type="Iptrunk", lifecycle=SubscriptionLifecycle.ACTIVE, includes=includes) + return get_subscriptions( + product_types=[ProductType.IP_TRUNK], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=includes + ) -def get_active_trunks_that_terminate_on_router(subscription_id: UUIDstr) -> list[SubscriptionTable]: - """Get all IP trunk subscriptions that are active, and terminate on the given ``subscription_id`` of a Router. +def get_trunks_that_terminate_on_router( + subscription_id: UUIDstr, lifecycle_state: SubscriptionLifecycle +) -> list[SubscriptionTable]: + """Get all IP trunk subscriptions that terminate on the given ``subscription_id`` of a Router. - Given a ``subscription_id`` of a Router subscription, this method gives a list of all active IP trunk subscriptions - that terminate on this Router. + Given a ``subscription_id`` of a Router subscription, this method gives a list of all IP trunk subscriptions that + terminate on this Router. The given lifecycle state dictates the state of trunk subscriptions that are counted as + terminating on this router. - :param subscription_id: Subscription ID of a Router - :type subscription_id: UUIDstr + :param UUIDstr subscription_id: Subscription ID of a Router + :param SubscriptionLifecycle lifecycle_state: Required lifecycle state of the IP trunk :return: A list of IP trunk subscriptions :rtype: list[SubscriptionTable] @@ -120,18 +148,18 @@ def get_active_trunks_that_terminate_on_router(subscription_id: UUIDstr) -> list query_in_use_by_subscriptions(UUID(subscription_id)) .join(ProductTable) .filter( - ProductTable.product_type == "Iptrunk", - SubscriptionTable.status == "active", + ProductTable.product_type == ProductType.IP_TRUNK, + SubscriptionTable.status == lifecycle_state, ) .all() ) -def get_product_id_by_name(product_name: ProductType) -> UUID: +def get_product_id_by_name(product_name: ProductName) -> UUID: """Retrieve the :term:`UUID` of a product by its name. :param product_name: The name of the product. - :type product_name: ProductType + :type product_name: ProductName :return UUID: The :term:`UUID` of the product. :rtype: UUID diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json index 46f0e96e201e09fd1e8b3e9973084289e108f075..fe687f342d00b95c0a44c1045a3e9718f8c1df47 100644 --- a/gso/translations/en-GB.json +++ b/gso/translations/en-GB.json @@ -38,10 +38,12 @@ "workflow": { "activate_iptrunk": "Activate IP Trunk", "activate_router": "Activate router", + "cancel_subscription": "Cancel subscription", "confirm_info": "Please verify this form looks correct.", "deploy_twamp": "Deploy TWAMP", "migrate_iptrunk": "Migrate IP Trunk", "modify_isis_metric": "Modify the ISIS metric", + "modify_site": "Modify site", "modify_trunk_interface": "Modify IP Trunk interface", "redeploy_base_config": "Redeploy base config", "update_ibgp_mesh": "Update iBGP mesh" diff --git a/gso/utils/shared_enums.py b/gso/utils/shared_enums.py index 4861e9e134ecf113d74772a08282e7928829d19b..c0116e1690d6384cabd9ce16cf1ee79201a0d6b8 100644 --- a/gso/utils/shared_enums.py +++ b/gso/utils/shared_enums.py @@ -19,3 +19,10 @@ class PortNumber(ConstrainedInt): gt = 0 le = 49151 + + +class ConnectionStrategy(strEnum): + """An enumerator for the connection Strategies.""" + + IN_BAND = "IN BAND" + OUT_OF_BAND = "OUT OF BAND" diff --git a/gso/utils/workflow_steps.py b/gso/utils/workflow_steps.py index 522ceaf58587fbfe7ae4555637431c5e90568ca7..7b3b5cc91fe5899b1c8580501d85da3a638459d9 100644 --- a/gso/utils/workflow_steps.py +++ b/gso/utils/workflow_steps.py @@ -63,7 +63,7 @@ def deploy_base_config_real( return {"subscription": subscription} -@step("[COMMIT] Set ISIS metric to very high value") +@step("[FOR REAL] Set ISIS metric to very high value") def set_isis_to_max(subscription: Iptrunk, process_id: UUIDstr, callback_route: str, tt_number: str) -> State: """Workflow step for setting the :term:`ISIS` metric to an arbitrarily high value to drain a link.""" old_isis_metric = subscription.iptrunk.iptrunk_isis_metric @@ -92,7 +92,7 @@ def set_isis_to_max(subscription: Iptrunk, process_id: UUIDstr, callback_route: } -@step("[CHECK] Run show commands after base config install") +@step("Run show commands after base config install") def run_checks_after_base_config(subscription: dict[str, Any], callback_route: str) -> None: """Workflow step for running show commands after installing base config.""" execute_playbook( diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py index d25088730e93388aadef15057e80d3eca8a93ce6..05848a322c6b893231a8c754954c43ecf38da943 100644 --- a/gso/workflows/__init__.py +++ b/gso/workflows/__init__.py @@ -5,6 +5,7 @@ from orchestrator.workflows import LazyWorkflowInstance WF_USABLE_MAP.update( { + "cancel_subscription": ["initial"], "redeploy_base_config": ["provisioning", "active"], "update_ibgp_mesh": ["provisioning", "active"], "activate_router": ["provisioning"], @@ -26,6 +27,8 @@ LazyWorkflowInstance("gso.workflows.router.create_router", "create_router") LazyWorkflowInstance("gso.workflows.router.redeploy_base_config", "redeploy_base_config") LazyWorkflowInstance("gso.workflows.router.terminate_router", "terminate_router") LazyWorkflowInstance("gso.workflows.router.update_ibgp_mesh", "update_ibgp_mesh") +LazyWorkflowInstance("gso.workflows.router.modify_connection_strategy", "modify_connection_strategy") +LazyWorkflowInstance("gso.workflows.shared.cancel_subscription", "cancel_subscription") LazyWorkflowInstance("gso.workflows.site.create_site", "create_site") LazyWorkflowInstance("gso.workflows.site.modify_site", "modify_site") LazyWorkflowInstance("gso.workflows.site.terminate_site", "terminate_site") diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index 481ca07838d523e366e7c8387442c0a8dba94336..378f32b0beebfc9012bab8605f01d6f3f33aa891 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -253,7 +253,7 @@ def initialize_subscription( return {"subscription": subscription} -@step("Provision IP trunk interface [DRY RUN]") +@step("[DRY RUN] Provision IP trunk interface") def provision_ip_trunk_iface_dry( subscription: IptrunkInactive, callback_route: str, @@ -281,7 +281,7 @@ def provision_ip_trunk_iface_dry( return {"subscription": subscription} -@step("Provision IP trunk interface [FOR REAL]") +@step("[FOR REAL] Provision IP trunk interface") def provision_ip_trunk_iface_real( subscription: IptrunkInactive, callback_route: str, @@ -327,7 +327,7 @@ def check_ip_trunk_connectivity( return {"subscription": subscription} -@step("Provision IP trunk ISIS interface [DRY RUN]") +@step("[DRY RUN] Provision IP trunk ISIS interface") def provision_ip_trunk_isis_iface_dry( subscription: IptrunkInactive, callback_route: str, @@ -355,7 +355,7 @@ def provision_ip_trunk_isis_iface_dry( return {"subscription": subscription} -@step("Provision IP trunk ISIS interface [FOR REAL]") +@step("[FOR REAL] Provision IP trunk ISIS interface") def provision_ip_trunk_isis_iface_real( subscription: IptrunkInactive, callback_route: str, diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 3ee509cb23ea44bc236ba7f7158b65200c9741aa..add6d5f01d6c5d74f31dc426453856f4240bf963 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -276,7 +276,7 @@ def disable_old_config_dry( return {"subscription": subscription} -@step("[REAL] Disable configuration on old router") +@step("[FOR REAL] Disable configuration on old router") def disable_old_config_real( subscription: Iptrunk, callback_route: str, @@ -352,7 +352,7 @@ def deploy_new_config_dry( return {"subscription": subscription} -@step("Deploy configuration on new router") +@step("[FOR REAL] Deploy configuration on new router") def deploy_new_config_real( subscription: Iptrunk, callback_route: str, @@ -423,7 +423,7 @@ def check_ip_trunk_connectivity( return {"subscription": subscription} -@step("Deploy ISIS configuration on new router") +@step("[FOR REAL] Deploy ISIS configuration on new router") def deploy_new_isis( subscription: Iptrunk, callback_route: str, @@ -494,7 +494,7 @@ def confirm_continue_restore_isis() -> FormGenerator: return {} -@step("Restore ISIS metric to original value") +@step("[FOR REAL] Restore ISIS metric to original value") def restore_isis_metric( subscription: Iptrunk, process_id: UUIDstr, @@ -561,7 +561,7 @@ def delete_old_config_dry( return {"subscription": subscription} -@step("Delete configuration on old router") +@step("[FOR REAL] Delete configuration on old router") def delete_old_config_real( subscription: Iptrunk, callback_route: str, diff --git a/gso/workflows/iptrunk/modify_isis_metric.py b/gso/workflows/iptrunk/modify_isis_metric.py index 55fea705f8af6595933df3786a96de4bf962b953..b9e0a54934d7cff5f891bdce787f17724999b4f3 100644 --- a/gso/workflows/iptrunk/modify_isis_metric.py +++ b/gso/workflows/iptrunk/modify_isis_metric.py @@ -35,7 +35,7 @@ def modify_iptrunk_subscription(subscription: Iptrunk, isis_metric: int) -> Stat return {"subscription": subscription} -@step("Provision IP trunk ISIS interface [DRY RUN]") +@step("[DRY RUN] Provision IP trunk ISIS interface") def provision_ip_trunk_isis_iface_dry( subscription: Iptrunk, process_id: UUIDstr, @@ -63,7 +63,7 @@ def provision_ip_trunk_isis_iface_dry( return {"subscription": subscription} -@step("Provision IP trunk ISIS interface [FOR REAL]") +@step("[FOR REAL] Provision IP trunk ISIS interface") def provision_ip_trunk_isis_iface_real( subscription: Iptrunk, process_id: UUIDstr, diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 86b43eb55955857148c9184ae9963858bced337c..329d84aa6ef77002e1cc89bf3aa623672dd16167 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -214,7 +214,7 @@ def modify_iptrunk_subscription( } -@step("Provision IP trunk interface [DRY RUN]") +@step("[DRY RUN] Provision IP trunk interface") def provision_ip_trunk_iface_dry( subscription: Iptrunk, process_id: UUIDstr, @@ -244,7 +244,7 @@ def provision_ip_trunk_iface_dry( return {"subscription": subscription} -@step("Provision IP trunk interface [FOR REAL]") +@step("[FOR REAL] Provision IP trunk interface") def provision_ip_trunk_iface_real( subscription: Iptrunk, process_id: UUIDstr, diff --git a/gso/workflows/iptrunk/terminate_iptrunk.py b/gso/workflows/iptrunk/terminate_iptrunk.py index 469491ebde407ddb5d92e9ffbb32e96fbb12d994..7a2afe6c22f0e2a1f381c92734f0efd5a8dbbb25 100644 --- a/gso/workflows/iptrunk/terminate_iptrunk.py +++ b/gso/workflows/iptrunk/terminate_iptrunk.py @@ -49,7 +49,7 @@ def initial_input_form_generator() -> FormGenerator: return user_input.dict() -@step("Deprovision IP trunk [DRY RUN]") +@step("[DRY RUN] Deprovision IP trunk") def deprovision_ip_trunk_dry(subscription: Iptrunk, process_id: UUIDstr, callback_route: str, tt_number: str) -> State: """Perform a dry run of deleting configuration from the routers.""" extra_vars = { @@ -72,7 +72,7 @@ def deprovision_ip_trunk_dry(subscription: Iptrunk, process_id: UUIDstr, callbac return {"subscription": subscription} -@step("Deprovision IP trunk [FOR REAL]") +@step("[FOR REAL] Deprovision IP trunk") def deprovision_ip_trunk_real(subscription: Iptrunk, process_id: UUIDstr, callback_route: str, tt_number: str) -> State: """Delete configuration from the routers.""" extra_vars = { diff --git a/gso/workflows/router/modify_connection_strategy.py b/gso/workflows/router/modify_connection_strategy.py new file mode 100644 index 0000000000000000000000000000000000000000..a3f5b5ae2f1f8cd0aa58d1d407d2daf28662c8a0 --- /dev/null +++ b/gso/workflows/router/modify_connection_strategy.py @@ -0,0 +1,52 @@ +"""Modify connection strategy workflow. Flipping the connection between in-band to out-of-band.""" + +from orchestrator.forms import FormPage +from orchestrator.targets import Target +from orchestrator.types import FormGenerator, State, UUIDstr +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_types.router import Router +from gso.utils.shared_enums import ConnectionStrategy + + +def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: + """Modify the connection strategy initial formruff format.""" + subscription = Router.from_subscription(subscription_id) + + current_connection_strategy = ( + ConnectionStrategy.OUT_OF_BAND if subscription.router.router_access_via_ts else ConnectionStrategy.IN_BAND + ) + + class ModifyConnectionStrategyForm(FormPage): + class Config: + title = f"Modify the connection strategy of {subscription.router.router_fqdn}." + + connection_strategy: ConnectionStrategy = current_connection_strategy + + user_input = yield ModifyConnectionStrategyForm + + return user_input.dict() + + +@step("Update subscription model") +def update_subscription_model(subscription: Router, connection_strategy: str) -> State: + """Update the database model to reflect the new connection strategy. + + If the connection strategy is set to IN-BAND, then access_via_ts should be set to False. + Conversely, if the connection strategy is set to OUT-OF-BAND, access_via_ts should be set to True. + """ + subscription.router.router_access_via_ts = connection_strategy == ConnectionStrategy.OUT_OF_BAND + + return {"subscription": subscription} + + +@workflow( + "Modify connection strategy", + initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator), + target=Target.MODIFY, +) +def modify_connection_strategy() -> StepList: + """Modify the connection strategy.""" + return init >> store_process_subscription(Target.MODIFY) >> unsync >> update_subscription_model >> resync >> done diff --git a/gso/workflows/router/update_ibgp_mesh.py b/gso/workflows/router/update_ibgp_mesh.py index 80f63d21fbcf9c4a87545364504198f3fe67cde4..abef7da440b8024b578a7122be54f4440eb6764a 100644 --- a/gso/workflows/router/update_ibgp_mesh.py +++ b/gso/workflows/router/update_ibgp_mesh.py @@ -6,7 +6,7 @@ from orchestrator.config.assignee import Assignee from orchestrator.forms import FormPage from orchestrator.forms.validators import Label from orchestrator.targets import Target -from orchestrator.types import FormGenerator, State, UUIDstr +from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr from orchestrator.workflow import StepList, done, init, inputstep, step, workflow from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form @@ -16,7 +16,7 @@ from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import Router from gso.services import librenms_client, lso_client, subscriptions from gso.services.lso_client import lso_interaction -from gso.services.subscriptions import get_active_trunks_that_terminate_on_router +from gso.services.subscriptions import get_trunks_that_terminate_on_router from gso.utils.helpers import SNMPVersion @@ -36,8 +36,11 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @root_validator(allow_reuse=True) def router_has_a_trunk(cls, values: dict[str, Any]) -> dict[str, Any]: - if len(get_active_trunks_that_terminate_on_router(subscription_id)) == 0: - msg = "Selected router does not terminate any active IP trunks." + terminating_trunks = get_trunks_that_terminate_on_router( + subscription_id, SubscriptionLifecycle.PROVISIONING + ) + get_trunks_that_terminate_on_router(subscription_id, SubscriptionLifecycle.ACTIVE) + if len(terminating_trunks) == 0: + msg = "Selected router does not terminate any available IP trunks." raise ValueError(msg) return values diff --git a/gso/workflows/shared/__init__.py b/gso/workflows/shared/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..daa25805d2c02ae05d7292e4a74a649d93a07e6b --- /dev/null +++ b/gso/workflows/shared/__init__.py @@ -0,0 +1 @@ +"""Workflows that are shared across multiple products.""" diff --git a/gso/workflows/shared/cancel_subscription.py b/gso/workflows/shared/cancel_subscription.py new file mode 100644 index 0000000000000000000000000000000000000000..79bb05905f6940ce3eda6543d960acf41818fb4f --- /dev/null +++ b/gso/workflows/shared/cancel_subscription.py @@ -0,0 +1,47 @@ +"""Cancel a subscription that is in the initial lifecycle state.""" + +from orchestrator.forms import FormPage +from orchestrator.forms.validators import Label +from orchestrator.targets import Target +from orchestrator.types import FormGenerator, SubscriptionLifecycle, UUIDstr +from orchestrator.workflow import StepList, done, init, workflow +from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync +from orchestrator.workflows.utils import wrap_modify_initial_input_form + + +def _initial_input_form(subscription_id: UUIDstr) -> FormGenerator: + class CancelSubscriptionForm(FormPage): + info_label: Label = f"Canceling subscription with ID {subscription_id}" # type:ignore[assignment] + info_label_2: Label = ( + "This will immediately mark the subscription as terminated, preventing any other workflows from interacting" # type:ignore[assignment] + " with this product subscription." + ) + info_label_3: Label = "ONLY EXECUTE THIS WORKFLOW WHEN YOU ARE ABSOLUTELY SURE WHAT YOU ARE DOING." # type:ignore[assignment] + info_label_4: Label = "THIS WORKFLOW IS IRREVERSIBLE AND MAY HAVE UNFORESEEN CONSEQUENCES." # type:ignore[assignment] + + yield CancelSubscriptionForm + + return {"subscription_id": subscription_id} + + +@workflow( + "Cancel an initial subscription", + initial_input_form=wrap_modify_initial_input_form(_initial_input_form), + target=Target.TERMINATE, +) +def cancel_subscription() -> StepList: + """Cancel an initial subscription, taking it from the ``INITIAL`` state straight to ``TERMINATED``. + + This workflow can be used when a creation workflow has failed, and the process needs to be restarted. This workflow + will prevent a stray subscription, forever stuck in the initial state, to stick around. + + * Update the subscription lifecycle state to ``TERMINATED``. + """ + return ( + init + >> store_process_subscription(Target.TERMINATE) + >> unsync + >> set_status(SubscriptionLifecycle.TERMINATED) + >> resync + >> done + ) diff --git a/gso/workflows/tasks/import_iptrunk.py b/gso/workflows/tasks/import_iptrunk.py index 877c375e3b88b858c8e3484b8943ef80deed8250..c34be8ed4e8e9564b19eca4f9f56ef4c27b2f7bd 100644 --- a/gso/workflows/tasks/import_iptrunk.py +++ b/gso/workflows/tasks/import_iptrunk.py @@ -11,7 +11,7 @@ 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 import ProductName from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlockInactive, IptrunkType, PhyPortCapacity from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning from gso.products.product_types.router import Router @@ -68,7 +68,7 @@ def initial_input_form_generator() -> FormGenerator: def create_subscription(partner: str) -> State: """Create a new subscription in the service database.""" partner_id = get_partner_by_name(partner)["partner_id"] - product_id = subscriptions.get_product_id_by_name(ProductType.IP_TRUNK) + product_id = subscriptions.get_product_id_by_name(ProductName.IP_TRUNK) subscription = IptrunkInactive.from_product_id(product_id, partner_id) return { diff --git a/gso/workflows/tasks/import_office_router.py b/gso/workflows/tasks/import_office_router.py index 255c7f3133763609edc443f80d8199cef838882f..9168cdae0150a82a1893b6b3ceae450b5df542b5 100644 --- a/gso/workflows/tasks/import_office_router.py +++ b/gso/workflows/tasks/import_office_router.py @@ -9,7 +9,7 @@ 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 import ProductName from gso.products.product_types import office_router from gso.products.product_types.office_router import OfficeRouterInactive from gso.services import subscriptions @@ -22,7 +22,7 @@ from gso.utils.shared_enums import PortNumber, Vendor def create_subscription(partner: str) -> State: """Create a new subscription object.""" partner_id = get_partner_by_name(partner)["partner_id"] - product_id = subscriptions.get_product_id_by_name(ProductType.OFFICE_ROUTER) + product_id = subscriptions.get_product_id_by_name(ProductName.OFFICE_ROUTER) subscription = OfficeRouterInactive.from_product_id(product_id, partner_id) return { diff --git a/gso/workflows/tasks/import_router.py b/gso/workflows/tasks/import_router.py index d284dbc56c9dc628739d918b47b5c7d93f2f1d28..c71ce26ee47a0e0929842d7261d7c0fd195d2e55 100644 --- a/gso/workflows/tasks/import_router.py +++ b/gso/workflows/tasks/import_router.py @@ -9,7 +9,7 @@ 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 import ProductName from gso.products.product_blocks import router as router_pb from gso.products.product_blocks.router import RouterRole from gso.products.product_types import router @@ -25,7 +25,7 @@ from gso.utils.shared_enums import PortNumber, Vendor def create_subscription(partner: str) -> State: """Create a new subscription object.""" partner_id = get_partner_by_name(partner)["partner_id"] - product_id = subscriptions.get_product_id_by_name(ProductType.ROUTER) + product_id = subscriptions.get_product_id_by_name(ProductName.ROUTER) subscription = RouterInactive.from_product_id(product_id, partner_id) return { diff --git a/gso/workflows/tasks/import_site.py b/gso/workflows/tasks/import_site.py index 026ffb1be99abab4f34d04f5cc64d72eba66f1c6..ff49808a5a86d1e73c6a741d74a75c4c7c233471 100644 --- a/gso/workflows/tasks/import_site.py +++ b/gso/workflows/tasks/import_site.py @@ -8,7 +8,7 @@ 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 import ProductName from gso.products.product_blocks.site import SiteTier from gso.products.product_types.site import SiteInactive from gso.services import subscriptions @@ -23,7 +23,7 @@ def create_subscription(partner: str) -> State: FIXME: all attributes passed by the input form appear to be unused """ partner_id = get_partner_by_name(partner)["partner_id"] - product_id: UUID = subscriptions.get_product_id_by_name(ProductType.SITE) + product_id: UUID = subscriptions.get_product_id_by_name(ProductName.SITE) subscription = SiteInactive.from_product_id(product_id, partner_id) return { diff --git a/gso/workflows/tasks/import_super_pop_switch.py b/gso/workflows/tasks/import_super_pop_switch.py index aa6c832fde157ce6bcb1e0d6bd73a5e62f4e35c5..5f2796c2c2325ad439a0570db5154f57a0b435f1 100644 --- a/gso/workflows/tasks/import_super_pop_switch.py +++ b/gso/workflows/tasks/import_super_pop_switch.py @@ -9,7 +9,7 @@ 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 import ProductName from gso.products.product_types import super_pop_switch from gso.products.product_types.super_pop_switch import SuperPopSwitchInactive from gso.services import subscriptions @@ -23,7 +23,7 @@ from gso.utils.shared_enums import PortNumber, Vendor def create_subscription(partner: str) -> State: """Create a new subscription object.""" partner_id = get_partner_by_name(partner)["partner_id"] - product_id = subscriptions.get_product_id_by_name(ProductType.SUPER_POP_SWITCH) + product_id = subscriptions.get_product_id_by_name(ProductName.SUPER_POP_SWITCH) subscription = SuperPopSwitchInactive.from_product_id(product_id, partner_id) return { diff --git a/setup.py b/setup.py index 3f5d75dc656e6416e47693fdc10235a230469d77..be3688d8df2ad8dda08a2f1ef611b261c1ce34ac 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="0.9", + version="1.0", author="GÉANT Orchestration and Automation Team", author_email="goat@geant.org", description="GÉANT Service Orchestrator", diff --git a/test/api/conftest.py b/test/api/conftest.py index e015f147d2b95bb8140ddabbf42ecadb68bb0302..e002fa13c19973dfbe733aa47fba34981558f116 100644 --- a/test/api/conftest.py +++ b/test/api/conftest.py @@ -2,5 +2,7 @@ from test.fixtures import ( # noqa: F401 iptrunk_side_subscription_factory, iptrunk_subscription_factory, nokia_router_subscription_factory, + office_router_subscription_factory, site_subscription_factory, + super_pop_switch_subscription_factory, ) diff --git a/test/api/test_subscriptions.py b/test/api/test_subscriptions.py index 37c74cd83bb24dfa32f338235983fb77ee7feee7..3e457a5879323360465a7b9a1ee23eb98627bfe3 100644 --- a/test/api/test_subscriptions.py +++ b/test/api/test_subscriptions.py @@ -1,6 +1,7 @@ from orchestrator.types import SubscriptionLifecycle ROUTER_SUBSCRIPTION_ENDPOINT = "/api/v1/subscriptions/routers" +DASHBOARD_DEVICES_ENDPOINT = "/api/v1/subscriptions/dashboard_devices" def test_router_subscriptions_endpoint_with_valid_api_key(test_client, nokia_router_subscription_factory): @@ -30,3 +31,59 @@ def test_router_subscriptions_endpoint_without_api_key(test_client, nokia_router assert response.status_code == 403 assert response.json() == {"detail": "Not authenticated"} + + +def test_dashboard_devices_endpoint_with_valid_api_key( + test_client, + nokia_router_subscription_factory, + office_router_subscription_factory, + super_pop_switch_subscription_factory, +): + nokia_router_subscription_factory(router_fqdn="mx1.ams.nl.geant.net") + nokia_router_subscription_factory(router_fqdn="mx2.ams.nl.geant.net") + nokia_router_subscription_factory(status=SubscriptionLifecycle.PROVISIONING, router_fqdn="mx3.ams.nl.geant.net") + nokia_router_subscription_factory(status=SubscriptionLifecycle.INITIAL, router_fqdn="mx4.ams.nl.geant.net") + office_router_subscription_factory(office_router_fqdn="office1.ams.nl.geant.net") + office_router_subscription_factory( + office_router_fqdn="office2.ams.nl.geant.net", status=SubscriptionLifecycle.TERMINATED + ) + office_router_subscription_factory( + office_router_fqdn="office3.ams.nl.geant.net", status=SubscriptionLifecycle.PROVISIONING + ) + super_pop_switch_subscription_factory(super_pop_switch_fqdn="superpop1.ams.nl.geant.net") + super_pop_switch_subscription_factory( + super_pop_switch_fqdn="superpop2.ams.nl.geant.net", status=SubscriptionLifecycle.TERMINATED + ) + super_pop_switch_subscription_factory( + super_pop_switch_fqdn="superpop3.ams.nl.geant.net", status=SubscriptionLifecycle.PROVISIONING + ) + + response = test_client.get( + DASHBOARD_DEVICES_ENDPOINT, headers={"Authorization": "Bearer another_REALY_random_AND_3cure_T0keN"} + ) + + assert response.status_code == 200 + fqdns = response.text.strip().split("\n") + assert sorted(fqdns) == [ + "mx1.ams.nl.geant.net", + "mx2.ams.nl.geant.net", + "mx3.ams.nl.geant.net", + "office1.ams.nl.geant.net", + "office3.ams.nl.geant.net", + "superpop1.ams.nl.geant.net", + "superpop3.ams.nl.geant.net", + ] + + +def test_dashboard_devices_endpoint_with_invalid_api_key(test_client, nokia_router_subscription_factory): + response = test_client.get(DASHBOARD_DEVICES_ENDPOINT, headers={"Authorization": "Bearer fake_invalid_api_key"}) + + assert response.status_code == 403 + assert response.json() == {"detail": "Invalid API Key"} + + +def test_dashboard_devices_endpoint_without_api_key(test_client, nokia_router_subscription_factory): + response = test_client.get(DASHBOARD_DEVICES_ENDPOINT) + + assert response.status_code == 403 + assert response.json() == {"detail": "Not authenticated"} diff --git a/test/conftest.py b/test/conftest.py index 96b4a64056c621b43e3dd724829bcda4835b3fe4..d0bfebfed7b8bf9e04e2d086fc5bd568550dc321 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -246,4 +246,4 @@ def test_client(fastapi_app): @pytest.fixture(scope="session") def geant_partner(): - return create_partner(PartnerCreate(name="GEANT-TEST", partner_type=PartnerType.GEANT)) + return create_partner(PartnerCreate(name="GEANT-TEST", partner_type=PartnerType.GEANT, email="goat-test@geant.org")) diff --git a/test/fixtures.py b/test/fixtures.py index 0800edec6a175435a7af60afa51cb3b99f1301b7..f0c55c2190041a837594b7907063abcdce69e04a 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -5,7 +5,7 @@ from orchestrator.db import db from orchestrator.domain import SubscriptionModel from orchestrator.types import SubscriptionLifecycle, UUIDstr -from gso.products import ProductType +from gso.products import ProductName from gso.products.product_blocks.iptrunk import ( IptrunkInterfaceBlock, IptrunkSideBlock, @@ -15,8 +15,10 @@ from gso.products.product_blocks.iptrunk import ( from gso.products.product_blocks.router import RouterRole from gso.products.product_blocks.site import SiteTier from gso.products.product_types.iptrunk import IptrunkInactive +from gso.products.product_types.office_router import OfficeRouterInactive from gso.products.product_types.router import Router, RouterInactive from gso.products.product_types.site import Site, SiteInactive +from gso.products.product_types.super_pop_switch import SuperPopSwitchInactive from gso.services import subscriptions from gso.utils.shared_enums import Vendor @@ -36,6 +38,7 @@ def site_subscription_factory(faker, geant_partner): site_internal_id=None, site_tier=SiteTier.TIER1, site_ts_address=None, + status: SubscriptionLifecycle | None = None, partner: dict | None = None, ) -> UUIDstr: if partner is None: @@ -52,7 +55,7 @@ def site_subscription_factory(faker, geant_partner): site_internal_id = site_internal_id or faker.pyint() site_ts_address = site_ts_address or faker.ipv4() - product_id = subscriptions.get_product_id_by_name(ProductType.SITE) + product_id = subscriptions.get_product_id_by_name(ProductName.SITE) site_subscription = SiteInactive.from_product_id(product_id, customer_id=partner["partner_id"], insync=True) site_subscription.site.site_city = site_city site_subscription.site.site_name = site_name @@ -68,6 +71,9 @@ def site_subscription_factory(faker, geant_partner): site_subscription = SubscriptionModel.from_other_lifecycle(site_subscription, SubscriptionLifecycle.ACTIVE) site_subscription.description = description site_subscription.start_date = start_date + if status: + site_subscription.status = status + site_subscription.save() db.session.commit() @@ -104,7 +110,7 @@ def nokia_router_subscription_factory(site_subscription_factory, faker, geant_pa router_lo_iso_address = router_lo_iso_address or faker.word() router_site = router_site or site_subscription_factory() - product_id = subscriptions.get_product_id_by_name(ProductType.ROUTER) + product_id = subscriptions.get_product_id_by_name(ProductName.ROUTER) router_subscription = RouterInactive.from_product_id(product_id, customer_id=partner["partner_id"], insync=True) router_subscription.router.router_fqdn = router_fqdn router_subscription.router.router_ts_port = router_ts_port @@ -159,7 +165,7 @@ def juniper_router_subscription_factory(site_subscription_factory, faker, geant_ router_lo_iso_address = router_lo_iso_address or faker.word() router_site = router_site or site_subscription_factory() - product_id = subscriptions.get_product_id_by_name(ProductType.ROUTER) + product_id = subscriptions.get_product_id_by_name(ProductName.ROUTER) router_subscription = RouterInactive.from_product_id(product_id, customer_id=partner["partner_id"], insync=True) router_subscription.router.router_fqdn = router_fqdn @@ -244,7 +250,7 @@ def iptrunk_subscription_factory(iptrunk_side_subscription_factory, faker, geant if partner is None: partner = geant_partner - product_id = subscriptions.get_product_id_by_name(ProductType.IP_TRUNK) + product_id = subscriptions.get_product_id_by_name(ProductName.IP_TRUNK) description = description or faker.sentence() geant_s_sid = geant_s_sid or faker.geant_sid() @@ -286,3 +292,106 @@ def iptrunk_subscription_factory(iptrunk_side_subscription_factory, faker, geant return str(iptrunk_subscription.subscription_id) return subscription_create + + +@pytest.fixture() +def office_router_subscription_factory(site_subscription_factory, faker, geant_partner): + def subscription_create( + description=None, + start_date="2023-05-24T00:00:00+00:00", + office_router_fqdn=None, + office_router_ts_port=None, + office_router_lo_ipv4_address=None, + office_router_lo_ipv6_address=None, + office_router_site=None, + status: SubscriptionLifecycle | None = None, + partner: dict | None = None, + ) -> UUIDstr: + if partner is None: + partner = geant_partner + + description = description or faker.text(max_nb_chars=30) + office_router_fqdn = office_router_fqdn or faker.domain_name(levels=4) + office_router_ts_port = office_router_ts_port or faker.random_int(min=1, max=49151) + office_router_lo_ipv4_address = office_router_lo_ipv4_address or ipaddress.IPv4Address(faker.ipv4()) + office_router_lo_ipv6_address = office_router_lo_ipv6_address or ipaddress.IPv6Address(faker.ipv6()) + office_router_site = office_router_site or site_subscription_factory() + + product_id = subscriptions.get_product_id_by_name(ProductName.OFFICE_ROUTER) + office_router_subscription = OfficeRouterInactive.from_product_id( + product_id, customer_id=partner["partner_id"], insync=True + ) + office_router_subscription.office_router.office_router_fqdn = office_router_fqdn + office_router_subscription.office_router.office_router_ts_port = office_router_ts_port + office_router_subscription.office_router.office_router_lo_ipv4_address = office_router_lo_ipv4_address + office_router_subscription.office_router.office_router_lo_ipv6_address = office_router_lo_ipv6_address + office_router_subscription.office_router.office_router_site = Site.from_subscription(office_router_site).site + office_router_subscription.office_router.vendor = Vendor.NOKIA + + office_router_subscription = SubscriptionModel.from_other_lifecycle( + office_router_subscription, SubscriptionLifecycle.ACTIVE + ) + office_router_subscription.description = description + office_router_subscription.start_date = start_date + + if status: + office_router_subscription.status = status + + office_router_subscription.save() + db.session.commit() + + return str(office_router_subscription.subscription_id) + + return subscription_create + + +@pytest.fixture() +def super_pop_switch_subscription_factory(site_subscription_factory, faker, geant_partner): + def subscription_create( + description=None, + start_date="2023-05-24T00:00:00+00:00", + super_pop_switch_fqdn=None, + super_pop_switch_ts_port=None, + super_pop_switch_mgmt_ipv4_address=None, + super_pop_switch_site=None, + status: SubscriptionLifecycle | None = None, + partner: dict | None = None, + ) -> UUIDstr: + if partner is None: + partner = geant_partner + + description = description or faker.text(max_nb_chars=30) + super_pop_switch_fqdn = super_pop_switch_fqdn or faker.domain_name(levels=4) + super_pop_switch_ts_port = super_pop_switch_ts_port or faker.random_int(min=1, max=49151) + super_pop_switch_mgmt_ipv4_address = super_pop_switch_mgmt_ipv4_address or ipaddress.IPv4Address(faker.ipv4()) + super_pop_switch_site = super_pop_switch_site or site_subscription_factory() + + product_id = subscriptions.get_product_id_by_name(ProductName.SUPER_POP_SWITCH) + super_pop_switch_subscription = SuperPopSwitchInactive.from_product_id( + product_id, customer_id=partner["partner_id"], insync=True + ) + super_pop_switch_subscription.super_pop_switch.super_pop_switch_fqdn = super_pop_switch_fqdn + super_pop_switch_subscription.super_pop_switch.super_pop_switch_ts_port = super_pop_switch_ts_port + super_pop_switch_subscription.super_pop_switch.super_pop_switch_mgmt_ipv4_address = ( + super_pop_switch_mgmt_ipv4_address + ) + super_pop_switch_subscription.super_pop_switch.super_pop_switch_site = Site.from_subscription( + super_pop_switch_site + ).site + super_pop_switch_subscription.super_pop_switch.vendor = Vendor.NOKIA + + super_pop_switch_subscription = SubscriptionModel.from_other_lifecycle( + super_pop_switch_subscription, SubscriptionLifecycle.ACTIVE + ) + super_pop_switch_subscription.description = description + super_pop_switch_subscription.start_date = start_date + + if status: + super_pop_switch_subscription.status = status + + super_pop_switch_subscription.save() + db.session.commit() + + return str(super_pop_switch_subscription.subscription_id) + + return subscription_create diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index 38611a75e4f2ebf513fcd9875e8dac619dc1ff99..589fbabb9e9e918a6861af93a81827301a5627bb 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from gso.products import Iptrunk, ProductType +from gso.products import Iptrunk, ProductName from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity from gso.services.subscriptions import get_product_id_by_name from gso.utils.helpers import LAGMember @@ -116,7 +116,7 @@ def test_successful_iptrunk_creation_with_standard_lso_result( mock_create_host.return_value = None mock_allocate_v4_network.return_value = faker.ipv4_network(max_subnet=31) mock_allocate_v6_network.return_value = faker.ipv6_network(max_subnet=126) - product_id = get_product_id_by_name(ProductType.IP_TRUNK) + product_id = get_product_id_by_name(ProductName.IP_TRUNK) initial_site_data = [{"product": product_id}, *input_form_wizard_data] result, process_stat, step_log = run_workflow("create_iptrunk", initial_site_data) @@ -162,7 +162,7 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( ): mock_allocate_v4_network.return_value = faker.ipv4_network(max_subnet=31) mock_allocate_v6_network.return_value = faker.ipv6_network(max_subnet=126) - product_id = get_product_id_by_name(ProductType.IP_TRUNK) + product_id = get_product_id_by_name(ProductName.IP_TRUNK) initial_site_data = [{"product": product_id}, *input_form_wizard_data] result, process_stat, step_log = run_workflow("create_iptrunk", initial_site_data) @@ -195,7 +195,7 @@ def test_successful_iptrunk_creation_with_juniper_interface_names( mock_create_host.return_value = None mock_allocate_v4_network.return_value = faker.ipv4_network(max_subnet=31) mock_allocate_v6_network.return_value = faker.ipv6_network(max_subnet=126) - product_id = get_product_id_by_name(ProductType.IP_TRUNK) + product_id = get_product_id_by_name(ProductName.IP_TRUNK) initial_site_data = [{"product": product_id}, *input_form_wizard_data] result, process_stat, step_log = run_workflow("create_iptrunk", initial_site_data) diff --git a/test/workflows/router/test_create_router.py b/test/workflows/router/test_create_router.py index a6d61729946e11249b8773599edfd3d023499dbb..33244ae5bbc17feef0dbb7c4abbc72823b058526 100644 --- a/test/workflows/router/test_create_router.py +++ b/test/workflows/router/test_create_router.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from infoblox_client import objects -from gso.products import ProductType, Site +from gso.products import ProductName, Site from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import Router from gso.services.subscriptions import get_product_id_by_name @@ -51,7 +51,7 @@ def test_create_nokia_router_success( data_config_filename, ): # Set up mock return values - product_id = get_product_id_by_name(ProductType.ROUTER) + product_id = get_product_id_by_name(ProductName.ROUTER) mock_site = Site.from_subscription(router_creation_input_form_data["router_site"]).site mock_v4 = faker.ipv4() mock_v6 = faker.ipv6() @@ -169,7 +169,7 @@ def test_create_nokia_router_lso_failure( ) # Run workflow - product_id = get_product_id_by_name(ProductType.ROUTER) + product_id = get_product_id_by_name(ProductName.ROUTER) initial_router_data = [{"product": product_id}, router_creation_input_form_data] result, process_stat, step_log = run_workflow("create_router", initial_router_data) diff --git a/test/workflows/router/test_modify_connection_stratey.py b/test/workflows/router/test_modify_connection_stratey.py new file mode 100644 index 0000000000000000000000000000000000000000..669ad10fe6bfcecfa464b5115f4ec9adb180a8b2 --- /dev/null +++ b/test/workflows/router/test_modify_connection_stratey.py @@ -0,0 +1,23 @@ +import pytest + +from gso.products import Router +from gso.utils.shared_enums import ConnectionStrategy +from test.workflows import assert_complete, run_workflow + + +@pytest.mark.workflow() +def test_modify_connection_strategy(responses, nokia_router_subscription_factory): + subscription_id = nokia_router_subscription_factory(router_access_via_ts=True) + subscription = Router.from_subscription(subscription_id) + assert subscription.router.router_access_via_ts is True + form_data = [ + {"subscription_id": subscription_id}, + {"connection_strategy": ConnectionStrategy.IN_BAND}, + ] + result, _, _ = run_workflow("modify_connection_strategy", form_data) + assert_complete(result) + + # Fetch the updated subscription after running the workflow + updated_subscription = Router.from_subscription(subscription_id) + assert updated_subscription.status == "active" + assert updated_subscription.router.router_access_via_ts is False diff --git a/test/workflows/router/test_update_ibgp_mesh.py b/test/workflows/router/test_update_ibgp_mesh.py index b2f6756b8d7820e8e9dedd9f0f23b644eb86bb67..136a4a6196a5cf3cf124f79c481287138e22a50c 100644 --- a/test/workflows/router/test_update_ibgp_mesh.py +++ b/test/workflows/router/test_update_ibgp_mesh.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pytest +from orchestrator.types import SubscriptionLifecycle from orchestrator.workflow import StepStatus from pydantic_forms.exceptions import FormValidationError @@ -16,23 +17,22 @@ from test.workflows import ( ) -@pytest.fixture() -def ibgp_mesh_input_form_data(iptrunk_subscription_factory, faker): - ip_trunk = Iptrunk.from_subscription(iptrunk_subscription_factory()) - - return {"subscription_id": ip_trunk.iptrunk.iptrunk_sides[0].iptrunk_side_node.owner_subscription_id} - - +@pytest.mark.parametrize("trunk_status", [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE]) @pytest.mark.workflow() @patch("gso.workflows.router.update_ibgp_mesh.lso_client.execute_playbook") @patch("gso.workflows.router.update_ibgp_mesh.librenms_client.LibreNMSClient.add_device") def test_update_ibgp_mesh_success( mock_librenms_add_device, mock_execute_playbook, - ibgp_mesh_input_form_data, + trunk_status, + iptrunk_subscription_factory, data_config_filename, faker, ): + ip_trunk = Iptrunk.from_subscription(iptrunk_subscription_factory(status=trunk_status)) + ibgp_mesh_input_form_data = { + "subscription_id": ip_trunk.iptrunk.iptrunk_sides[0].iptrunk_side_node.owner_subscription_id + } result, process_stat, step_log = run_workflow( "update_ibgp_mesh", [ibgp_mesh_input_form_data, {"tt_number": faker.tt_number()}] ) @@ -53,10 +53,23 @@ def test_update_ibgp_mesh_success( assert state["subscription"]["router"]["router_access_via_ts"] is False +@pytest.mark.parametrize("trunk_status", [SubscriptionLifecycle.INITIAL, SubscriptionLifecycle.TERMINATED]) +@pytest.mark.workflow() +def test_update_ibgp_mesh_failure(iptrunk_subscription_factory, data_config_filename, trunk_status): + ip_trunk = Iptrunk.from_subscription(iptrunk_subscription_factory(status=trunk_status)) + ibgp_mesh_input_form_data = { + "subscription_id": ip_trunk.iptrunk.iptrunk_sides[0].iptrunk_side_node.owner_subscription_id + } + + exception_message = "Selected router does not terminate any available IP trunks." + with pytest.raises(FormValidationError, match=exception_message): + run_workflow("update_ibgp_mesh", [ibgp_mesh_input_form_data, {}]) + + @pytest.mark.workflow() def test_update_ibgp_mesh_isolated_router(nokia_router_subscription_factory, data_config_filename): router_id = nokia_router_subscription_factory(router_role=RouterRole.P) - exception_message = "Selected router does not terminate any active IP trunks." + exception_message = "Selected router does not terminate any available IP trunks." with pytest.raises(FormValidationError, match=exception_message): run_workflow("update_ibgp_mesh", [{"subscription_id": router_id}, {}]) diff --git a/test/workflows/shared/__init__.py b/test/workflows/shared/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test/workflows/shared/cancel_subscription.py b/test/workflows/shared/cancel_subscription.py new file mode 100644 index 0000000000000000000000000000000000000000..9963b8f21066cfd02408a819b8ce2ae5facf8cba --- /dev/null +++ b/test/workflows/shared/cancel_subscription.py @@ -0,0 +1,30 @@ +import pytest +from orchestrator.domain.base import SubscriptionModel +from orchestrator.types import SubscriptionLifecycle + +from test.workflows import assert_complete, extract_state, run_workflow + + +@pytest.mark.parametrize( + "subscription_factory", + [ + "site_subscription_factory", + "juniper_router_subscription_factory", + "nokia_router_subscription_factory", + "iptrunk_subscription_factory", + ], +) +@pytest.mark.workflow() +def test_cancel_workflow_success(subscription_factory, geant_partner, request): + subscription_id = request.getfixturevalue(subscription_factory)( + status=SubscriptionLifecycle.INITIAL, + partner=geant_partner, + ) + initial_site_data = [{"subscription_id": subscription_id}, {}] + result, _, _ = run_workflow("cancel_subscription", initial_site_data) + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = SubscriptionModel.from_subscription(subscription_id) + assert subscription.status == "terminated" diff --git a/test/workflows/site/test_create_site.py b/test/workflows/site/test_create_site.py index 5909b5db9d19fca60d7815befd1a60d7d3676086..e31576152634045e9efe57b864a51785495a41d1 100644 --- a/test/workflows/site/test_create_site.py +++ b/test/workflows/site/test_create_site.py @@ -1,7 +1,7 @@ import pytest from pydantic_forms.exceptions import FormValidationError -from gso.products import ProductType +from gso.products import ProductName from gso.products.product_blocks.site import SiteTier from gso.products.product_types.site import Site from gso.services.partners import get_partner_by_name @@ -11,7 +11,7 @@ from test.workflows import assert_complete, extract_state, run_workflow @pytest.mark.workflow() def test_create_site(responses, faker): - product_id = get_product_id_by_name(ProductType.SITE) + product_id = get_product_id_by_name(ProductName.SITE) initial_site_data = [ {"product": product_id}, { @@ -51,7 +51,7 @@ def test_site_name_is_incorrect(responses, faker): """ invalid_site_name = "AMST10" expected_exception_msg = rf".*Enter a valid site name.+Received: {invalid_site_name}.*" - product_id = get_product_id_by_name(ProductType.SITE) + product_id = get_product_id_by_name(ProductName.SITE) initial_site_data = [ {"product": product_id}, {