diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45164592a74d3f5e59204397932331edde5141a3..a825c2db1f8b768cee467c4c074bfb1b50fdac17 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,8 +10,3 @@ repos: - --preview - --ignore=PLR0917,PLR0914 - --extend-exclude=test/* - # Run the formatter. - - id: ruff-format - args: - - --preview - - --exclude=test/* diff --git a/Changelog.md b/Changelog.md index d87116de079b184e123062fd106f19bcd2abc6c9..43ee4a2cea5e839c0e626e65a53c650d31ca4e83 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,12 @@ # Changelog All notable changes to this project will be documented in this file. + +## [0.8] - 2024-02-28 +- Add two new workflows for "activating" `Router` and `Iptrunk` products. +- Update lifecycle states for `Router` and `Iptrunk` products. +- Fix an issue in the Infoblox client when using a custom `dns_view`. + ## [0.7] - 2024-02-21 - Infoblox client: added support for the `network_view` (IPAM). diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 0bbcce276f79bee067aef114ea5b0f0c3101ab39..432f73de9e8cdd7f7fd38c5e99f9b434fb1fd82c 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -51,6 +51,9 @@ Glossary of terms NET Network Entity Title: used for :term:`ISIS` routing. + OOB + Out-of-band access + OSS Operational Support Systems diff --git a/docs/source/module/products/product_blocks/index.rst b/docs/source/module/products/product_blocks/index.rst index 309e18b62f81f25b4cfffe6c60fd77b46bd07d06..d106bb2ca83b6c2ffca86694249113bd310c0560 100644 --- a/docs/source/module/products/product_blocks/index.rst +++ b/docs/source/module/products/product_blocks/index.rst @@ -14,6 +14,8 @@ Submodules .. toctree:: :maxdepth: 1 + super_pop_switch + office_router iptrunk router site diff --git a/docs/source/module/products/product_blocks/office_router.rst b/docs/source/module/products/product_blocks/office_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..4d6b64f990c83bb5b7ae288c0d0f460b5e1a7a63 --- /dev/null +++ b/docs/source/module/products/product_blocks/office_router.rst @@ -0,0 +1,6 @@ +``gso.products.product_blocks.office_router`` +============================================= + +.. automodule:: gso.products.product_blocks.office_router + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_blocks/super_pop_switch.rst b/docs/source/module/products/product_blocks/super_pop_switch.rst new file mode 100644 index 0000000000000000000000000000000000000000..1f3808a75ba3b391cefb61fb3edc6fecb6c49b91 --- /dev/null +++ b/docs/source/module/products/product_blocks/super_pop_switch.rst @@ -0,0 +1,6 @@ +``gso.products.product_blocks.super_pop_switch`` +================================================ + +.. automodule:: gso.products.product_blocks.super_pop_switch + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_types/index.rst b/docs/source/module/products/product_types/index.rst index 0f79a699cb591afbadf08bb70e001610ef4adcf6..de30f01e26c15cb6f279a2720c1d90d1c21f4f15 100644 --- a/docs/source/module/products/product_types/index.rst +++ b/docs/source/module/products/product_types/index.rst @@ -14,6 +14,8 @@ Submodules .. toctree:: :maxdepth: 1 + super_pop_switch + office_router iptrunk router site diff --git a/docs/source/module/products/product_types/office_router.rst b/docs/source/module/products/product_types/office_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..d6e5b9974eaa2d53b8641ba099a564415a22d768 --- /dev/null +++ b/docs/source/module/products/product_types/office_router.rst @@ -0,0 +1,6 @@ +``gso.products.product_types.office_router`` +============================================ + +.. automodule:: gso.products.product_types.office_router + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_types/super_pop_switch.rst b/docs/source/module/products/product_types/super_pop_switch.rst new file mode 100644 index 0000000000000000000000000000000000000000..442e48ffe84d076d35f072e6a7ae72460cf0b37f --- /dev/null +++ b/docs/source/module/products/product_types/super_pop_switch.rst @@ -0,0 +1,6 @@ +``gso.products.product_types.super_pop_switch`` +=============================================== + +.. automodule:: gso.products.product_types.super_pop_switch + :members: + :show-inheritance: diff --git a/docs/source/module/utils/index.rst b/docs/source/module/utils/index.rst index 43b99a3592c9fb4f25d6d6aeb9b53be61586fbab..0bb43e3e0af577d3d15f9ec1cc6e189a19928eb4 100644 --- a/docs/source/module/utils/index.rst +++ b/docs/source/module/utils/index.rst @@ -12,6 +12,7 @@ Submodules :maxdepth: 2 :titlesonly: + shared_choices device_info exceptions helpers diff --git a/docs/source/module/utils/shared_choices.rst b/docs/source/module/utils/shared_choices.rst new file mode 100644 index 0000000000000000000000000000000000000000..46460a304905ef81cdd14481cb316d242adc62bf --- /dev/null +++ b/docs/source/module/utils/shared_choices.rst @@ -0,0 +1,6 @@ +``gso.utils.shared_choices`` +============================ + +.. automodule:: gso.utils.shared_choices + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/activate_iptrunk.rst b/docs/source/module/workflows/iptrunk/activate_iptrunk.rst new file mode 100644 index 0000000000000000000000000000000000000000..9082a9df171dfa20c0a536f37d2fac6b09a9cd7b --- /dev/null +++ b/docs/source/module/workflows/iptrunk/activate_iptrunk.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.activate_iptrunk`` +========================================== + +.. automodule:: gso.workflows.iptrunk.activate_iptrunk + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/index.rst b/docs/source/module/workflows/iptrunk/index.rst index 089aa1249ae7597e14953f60da9120d38de4c3f8..f046983fd0c9650d0aa72315a48d875525b312f7 100644 --- a/docs/source/module/workflows/iptrunk/index.rst +++ b/docs/source/module/workflows/iptrunk/index.rst @@ -12,6 +12,7 @@ Submodules :maxdepth: 2 :titlesonly: + activate_iptrunk create_iptrunk migrate_iptrunk modify_isis_metric diff --git a/docs/source/module/workflows/router/activate_router.rst b/docs/source/module/workflows/router/activate_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..383502dd3d283c9bdcaf36d7b1cabae88fd06a29 --- /dev/null +++ b/docs/source/module/workflows/router/activate_router.rst @@ -0,0 +1,6 @@ +``gso.workflows.router.activate_router`` +======================================== + +.. automodule:: gso.workflows.router.activate_router + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/router/index.rst b/docs/source/module/workflows/router/index.rst index 11c3f71c734ac62f2e3ea194222eb6478816b335..e582a4402c8c17ba1292fcb1763cdcce193109a0 100644 --- a/docs/source/module/workflows/router/index.rst +++ b/docs/source/module/workflows/router/index.rst @@ -12,5 +12,8 @@ Submodules :maxdepth: 2 :titlesonly: + activate_router create_router + redeploy_base_config terminate_router + update_ibgp_mesh diff --git a/docs/source/module/workflows/router/redeploy_base_config.rst b/docs/source/module/workflows/router/redeploy_base_config.rst new file mode 100644 index 0000000000000000000000000000000000000000..1348f983d743d9c174cc0c6629a1447b838234c6 --- /dev/null +++ b/docs/source/module/workflows/router/redeploy_base_config.rst @@ -0,0 +1,6 @@ +``gso.workflows.router.redeploy_base_config`` +============================================= + +.. automodule:: gso.workflows.router.redeploy_base_config + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/router/update_ibgp_mesh.rst b/docs/source/module/workflows/router/update_ibgp_mesh.rst new file mode 100644 index 0000000000000000000000000000000000000000..213aacb758b8f7dad57260f4e9aa5d6e7d3edf12 --- /dev/null +++ b/docs/source/module/workflows/router/update_ibgp_mesh.rst @@ -0,0 +1,6 @@ +``gso.workflows.router.update_ibgp_mesh`` +========================================= + +.. automodule:: gso.workflows.router.update_ibgp_mesh + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/tasks/import_office_router.rst b/docs/source/module/workflows/tasks/import_office_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..a6b37df5df88e9e57590be22495dfa213cfba177 --- /dev/null +++ b/docs/source/module/workflows/tasks/import_office_router.rst @@ -0,0 +1,6 @@ +``gso.workflows.tasks.import_office_router`` +============================================ + +.. automodule:: gso.workflows.tasks.import_office_router + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/tasks/import_super_pop_switch.rst b/docs/source/module/workflows/tasks/import_super_pop_switch.rst new file mode 100644 index 0000000000000000000000000000000000000000..575db1e0e343ffbe1436c6822276524bbcf456a0 --- /dev/null +++ b/docs/source/module/workflows/tasks/import_super_pop_switch.rst @@ -0,0 +1,6 @@ +``gso.workflows.tasks.import_super_pop_switch`` +=============================================== + +.. automodule:: gso.workflows.tasks.import_super_pop_switch + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/tasks/index.rst b/docs/source/module/workflows/tasks/index.rst index 8feb3de360ac547ed291e2dfceb07d84e5724c75..1931adef62a66b169c5e5b423df4f31022cfa4d5 100644 --- a/docs/source/module/workflows/tasks/index.rst +++ b/docs/source/module/workflows/tasks/index.rst @@ -12,6 +12,8 @@ Submodules :maxdepth: 2 :titlesonly: + import_super_pop_switch + import_office_router import_iptrunk import_router import_site diff --git a/gso/api/v1/imports.py b/gso/api/v1/imports.py index 684d7b7eb43bd878b5d3919a768f7a70ab33172c..dd77bc80e6145ae35d75fdd9514cf564d606d98c 100644 --- a/gso/api/v1/imports.py +++ b/gso/api/v1/imports.py @@ -11,11 +11,12 @@ from pydantic import BaseModel, root_validator, validator from gso.auth.security import opa_security_default from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity -from gso.products.product_blocks.router import RouterRole, RouterVendor +from gso.products.product_blocks.router import RouterRole 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 BaseSiteValidatorModel, LAGMember +from gso.utils.shared_enums import PortNumber, Vendor router = APIRouter(prefix="/imports", tags=["Imports"], dependencies=[Depends(opa_security_default)]) @@ -50,7 +51,7 @@ class RouterImportModel(BaseModel): router_site: str hostname: str ts_port: int - router_vendor: RouterVendor + router_vendor: Vendor router_role: RouterRole router_lo_ipv4_address: ipaddress.IPv4Address router_lo_ipv6_address: ipaddress.IPv6Address @@ -136,6 +137,27 @@ class IptrunkImportModel(BaseModel): return values +class SuperPopSwitchImportModel(BaseModel): + """Required fields for importing an existing :class:`gso.product.product_types.super_pop_switch`.""" + + customer: str + super_pop_switch_site: str + hostname: str + super_pop_switch_ts_port: PortNumber + super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address + + +class OfficeRouterImportModel(BaseModel): + """Required fields for importing an existing :class:`gso.product.product_types.office_router`.""" + + customer: str + office_router_site: str + office_router_fqdn: str + office_router_ts_port: PortNumber + office_router_lo_ipv4_address: ipaddress.IPv4Address + office_router_lo_ipv6_address: ipaddress.IPv6Address + + def _start_process(process_name: str, data: dict) -> UUID: """Start a process and handle common exceptions.""" pid: UUID = processes.start_process(process_name, [data]) @@ -184,7 +206,7 @@ def import_router(router_data: RouterImportModel) -> dict[str, Any]: :raises HTTPException: If there's an error in the process. """ pid = _start_process("import_router", router_data.dict()) - return {"detail": "Router added successfully", "pid": pid} + return {"detail": "Router has been added successfully", "pid": pid} @router.post("/iptrunks", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel) @@ -200,4 +222,36 @@ def import_iptrunk(iptrunk_data: IptrunkImportModel) -> dict[str, Any]: :raises HTTPException: If there's an error in the process. """ pid = _start_process("import_iptrunk", iptrunk_data.dict()) - return {"detail": "Iptrunk added successfully", "pid": pid} + return {"detail": "Iptrunk has been added successfully", "pid": pid} + + +@router.post("/super-pop-switches", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel) +def import_super_pop_switch(super_pop_switch_data: SuperPopSwitchImportModel) -> dict[str, Any]: + """Import a Super PoP switch by running the import_super_pop_switch workflow. + + :param super_pop_switch_data: The Super PoP switch information to be imported. + :type super_pop_switch_data: SuperPopSwitchImportModel + + :return: A dictionary containing the process id of the started process and detail message. + :rtype: dict[str, Any] + + :raises HTTPException: If there's an error in the process. + """ + pid = _start_process("import_super_pop_switch", super_pop_switch_data.dict()) + return {"detail": "Super PoP switch has been added successfully", "pid": pid} + + +@router.post("/office-routers", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel) +def import_office_router(office_router_data: OfficeRouterImportModel) -> dict[str, Any]: + """Import a office router by running the import_office_router workflow. + + :param office_router_data: The office router information to be imported. + :type office_router_data: OfficeRouterImportModel + + :return: A dictionary containing the process id of the started process and detail message. + :rtype: dict[str, Any] + + :raises HTTPException: If there's an error in the process. + """ + pid = _start_process("import_office_router", office_router_data.dict()) + return {"detail": "Office router has been added successfully", "pid": pid} diff --git a/gso/cli/imports.py b/gso/cli/imports.py index 6aa55c5dfc6bb5701d4cf81f5db6d6a1c3244d12..1a20c14be3840b76a6085f8756af943ef27c9b3d 100644 --- a/gso/cli/imports.py +++ b/gso/cli/imports.py @@ -11,17 +11,23 @@ from pydantic import ValidationError from gso.api.v1.imports import ( IptrunkImportModel, + OfficeRouterImportModel, RouterImportModel, SiteImportModel, + SuperPopSwitchImportModel, import_iptrunk, + import_office_router, import_router, import_site, + import_super_pop_switch, ) from gso.services.subscriptions import get_active_subscriptions_by_field_and_value app: typer.Typer = typer.Typer() -T = TypeVar("T", SiteImportModel, RouterImportModel, IptrunkImportModel) +T = TypeVar( + "T", SiteImportModel, RouterImportModel, IptrunkImportModel, SuperPopSwitchImportModel, OfficeRouterImportModel +) common_filepath_option = typer.Option( default="data.json", @@ -89,6 +95,20 @@ def import_routers(filepath: str = common_filepath_option) -> None: generic_import_data(filepath, RouterImportModel, import_router, "hostname") +@app.command() +def import_super_pop_switches(filepath: str = common_filepath_option) -> None: + """Import Super PoP Switches into GSO.""" + # Use the import_data function to handle common import logic + generic_import_data(filepath, SuperPopSwitchImportModel, import_super_pop_switch, "hostname") + + +@app.command() +def import_office_routers(filepath: str = common_filepath_option) -> None: + """Import office routers into GSO.""" + # Use the import_data function to handle common import logic + generic_import_data(filepath, OfficeRouterImportModel, import_office_router, "office_router_fqdn") + + def get_router_subscription_id(node_name: str) -> str | None: """Get the subscription id for a router by its node name.""" subscriptions = get_active_subscriptions_by_field_and_value( diff --git a/gso/migrations/versions/2024-02-14_1c3c90ea1d8c_add_super_pop_switch_product.py b/gso/migrations/versions/2024-02-14_1c3c90ea1d8c_add_super_pop_switch_product.py new file mode 100644 index 0000000000000000000000000000000000000000..aac35ee3331ab554593c1015cd655d69869b8b3c --- /dev/null +++ b/gso/migrations/versions/2024-02-14_1c3c90ea1d8c_add_super_pop_switch_product.py @@ -0,0 +1,113 @@ +"""Add Super PoP switch product.. + +Revision ID: 1c3c90ea1d8c +Revises: bacd55c26106 +Create Date: 2024-02-14 11:00:11.858023 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '1c3c90ea1d8c' +down_revision = '113a81d2a40a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text(""" +INSERT INTO products (name, description, product_type, tag, status) VALUES ('Super PoP switch', 'Super PoP switch', 'SuperPopSwitch', 'Super_POP_SWITCH', 'active') RETURNING products.product_id + """)) + conn.execute(sa.text(""" +INSERT INTO product_blocks (name, description, tag, status) VALUES ('SuperPopSwitchBlock', 'Super PoP switch block', 'SUPER_POP_SWITCH_BLK', 'active') RETURNING product_blocks.product_block_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('super_pop_switch_ts_port', 'The port of the terminal server that this Super PoP switch is connected to. Used to offer out of band access.') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('super_pop_switch_fqdn', 'Super PoP switch FQDN.') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('super_pop_switch_mgmt_ipv4_address', 'The IPv4 management address of the Super PoP switch.') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO product_product_blocks (product_id, product_block_id) VALUES ((SELECT products.product_id FROM products WHERE products.name IN ('Super PoP switch')), (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('SuperPopSwitchBlock'))) + """)) + 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 ('SuperPopSwitchBlock')), (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('SiteBlock'))) + """)) + 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 ('SuperPopSwitchBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_fqdn'))) + """)) + 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 ('SuperPopSwitchBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_ts_port'))) + """)) + 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 ('SuperPopSwitchBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_mgmt_ipv4_address'))) + """)) + 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 ('SuperPopSwitchBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('vendor'))) + """)) + + +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 ('SuperPopSwitchBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_fqdn')) + """)) + 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 ('SuperPopSwitchBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_fqdn')) + """)) + 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 ('SuperPopSwitchBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_ts_port')) + """)) + 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 ('SuperPopSwitchBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_ts_port')) + """)) + 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 ('SuperPopSwitchBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_mgmt_ipv4_address')) + """)) + 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 ('SuperPopSwitchBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_mgmt_ipv4_address')) + """)) + 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 ('SuperPopSwitchBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('vendor')) + """)) + 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 ('SuperPopSwitchBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('vendor')) + """)) + 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 ('super_pop_switch_ts_port', 'super_pop_switch_fqdn', 'super_pop_switch_mgmt_ipv4_address')) + """)) + conn.execute(sa.text(""" +DELETE FROM resource_types WHERE resource_types.resource_type IN ('super_pop_switch_ts_port', 'super_pop_switch_fqdn', 'super_pop_switch_mgmt_ipv4_address') + """)) + conn.execute(sa.text(""" +DELETE FROM product_product_blocks WHERE product_product_blocks.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Super PoP switch')) AND product_product_blocks.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('SuperPopSwitchBlock')) + """)) + 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 ('SuperPopSwitchBlock')) AND product_block_relations.depends_on_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('SiteBlock')) + """)) + 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 ('SuperPopSwitchBlock')) + """)) + conn.execute(sa.text(""" +DELETE FROM product_blocks WHERE product_blocks.name IN ('SuperPopSwitchBlock') + """)) + conn.execute(sa.text(""" +DELETE FROM processes WHERE processes.pid IN (SELECT processes_subscriptions.pid FROM processes_subscriptions WHERE processes_subscriptions.subscription_id IN (SELECT subscriptions.subscription_id FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Super PoP switch')))) + """)) + conn.execute(sa.text(""" +DELETE FROM processes_subscriptions WHERE processes_subscriptions.subscription_id IN (SELECT subscriptions.subscription_id FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Super PoP switch'))) + """)) + conn.execute(sa.text(""" +DELETE FROM subscription_instances WHERE subscription_instances.subscription_id IN (SELECT subscriptions.subscription_id FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Super PoP switch'))) + """)) + conn.execute(sa.text(""" +DELETE FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Super PoP switch')) + """)) + conn.execute(sa.text(""" +DELETE FROM products WHERE products.name IN ('Super PoP switch') + """)) diff --git a/gso/migrations/versions/2024-02-20_6e4952687205_add_office_router_product.py b/gso/migrations/versions/2024-02-20_6e4952687205_add_office_router_product.py new file mode 100644 index 0000000000000000000000000000000000000000..631facbc24057107a3ae7274e379e397fe308fd5 --- /dev/null +++ b/gso/migrations/versions/2024-02-20_6e4952687205_add_office_router_product.py @@ -0,0 +1,125 @@ +"""Add office router product.. + +Revision ID: 6e4952687205 +Revises: 1c3c90ea1d8c +Create Date: 2024-02-20 11:22:30.804258 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '6e4952687205' +down_revision = '1c3c90ea1d8c' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text(""" +INSERT INTO products (name, description, product_type, tag, status) VALUES ('Office router', 'Office router product', 'OfficeRouter', 'OFFICE_ROUTER', 'active') RETURNING products.product_id + """)) + conn.execute(sa.text(""" +INSERT INTO product_blocks (name, description, tag, status) VALUES ('OfficeRouterBlock', 'Office router product block', 'OFFICE_ROUTER_BLOCK', 'active') RETURNING product_blocks.product_block_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('office_router_fqdn', 'Office router FQDN') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('office_router_ts_port', 'The port of the terminal server that this office router is connected to') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('office_router_lo_ipv4_address', 'The IPv4 loopback address of the office router.') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO resource_types (resource_type, description) VALUES ('office_router_lo_ipv6_address', 'The IPv6 loopback address of the office router.') RETURNING resource_types.resource_type_id + """)) + conn.execute(sa.text(""" +INSERT INTO product_product_blocks (product_id, product_block_id) VALUES ((SELECT products.product_id FROM products WHERE products.name IN ('Office router')), (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('OfficeRouterBlock'))) + """)) + 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 ('OfficeRouterBlock')), (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('SiteBlock'))) + """)) + 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 ('OfficeRouterBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_fqdn'))) + """)) + 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 ('OfficeRouterBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_ts_port'))) + """)) + 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 ('OfficeRouterBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_lo_ipv4_address'))) + """)) + 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 ('OfficeRouterBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_lo_ipv6_address'))) + """)) + 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 ('OfficeRouterBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('vendor'))) + """)) + + +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 ('OfficeRouterBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_fqdn')) + """)) + 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 ('OfficeRouterBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_fqdn')) + """)) + 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 ('OfficeRouterBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_ts_port')) + """)) + 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 ('OfficeRouterBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_ts_port')) + """)) + 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 ('OfficeRouterBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_lo_ipv4_address')) + """)) + 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 ('OfficeRouterBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_lo_ipv4_address')) + """)) + 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 ('OfficeRouterBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_lo_ipv6_address')) + """)) + 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 ('OfficeRouterBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('office_router_lo_ipv6_address')) + """)) + 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 ('OfficeRouterBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('vendor')) + """)) + 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 ('OfficeRouterBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('vendor')) + """)) + 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 ('office_router_fqdn', 'office_router_ts_port', 'office_router_lo_ipv4_address', 'office_router_lo_ipv6_address')) + """)) + conn.execute(sa.text(""" +DELETE FROM resource_types WHERE resource_types.resource_type IN ('office_router_fqdn', 'office_router_ts_port', 'office_router_lo_ipv4_address', 'office_router_lo_ipv6_address') + """)) + conn.execute(sa.text(""" +DELETE FROM product_product_blocks WHERE product_product_blocks.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Office router')) AND product_product_blocks.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('OfficeRouterBlock')) + """)) + 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 ('OfficeRouterBlock')) AND product_block_relations.depends_on_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('SiteBlock')) + """)) + 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 ('OfficeRouterBlock')) + """)) + conn.execute(sa.text(""" +DELETE FROM product_blocks WHERE product_blocks.name IN ('OfficeRouterBlock') + """)) + conn.execute(sa.text(""" +DELETE FROM processes WHERE processes.pid IN (SELECT processes_subscriptions.pid FROM processes_subscriptions WHERE processes_subscriptions.subscription_id IN (SELECT subscriptions.subscription_id FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Office router')))) + """)) + conn.execute(sa.text(""" +DELETE FROM processes_subscriptions WHERE processes_subscriptions.subscription_id IN (SELECT subscriptions.subscription_id FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Office router'))) + """)) + conn.execute(sa.text(""" +DELETE FROM subscription_instances WHERE subscription_instances.subscription_id IN (SELECT subscriptions.subscription_id FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Office router'))) + """)) + conn.execute(sa.text(""" +DELETE FROM subscriptions WHERE subscriptions.product_id IN (SELECT products.product_id FROM products WHERE products.name IN ('Office router')) + """)) + conn.execute(sa.text(""" +DELETE FROM products WHERE products.name IN ('Office router') + """)) diff --git a/gso/migrations/versions/2024-02-21_113a81d2a40a_add_router_activation_workflow.py b/gso/migrations/versions/2024-02-21_113a81d2a40a_add_router_activation_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..61237f322953bfaa4c55c0cfcb4690f3237cb988 --- /dev/null +++ b/gso/migrations/versions/2024-02-21_113a81d2a40a_add_router_activation_workflow.py @@ -0,0 +1,39 @@ +"""Add Router activation workflow. + +Revision ID: 113a81d2a40a +Revises: bacd55c26106 +Create Date: 2024-02-21 10:28:18.340922 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '113a81d2a40a' +down_revision = 'bacd55c26106' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import create_workflow, delete_workflow + +new_workflows = [ + { + "name": "activate_router", + "target": "MODIFY", + "description": "Activate a router", + "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-02-27_5bea5647f61d_add_ip_trunk_activation_workflow.py b/gso/migrations/versions/2024-02-27_5bea5647f61d_add_ip_trunk_activation_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..c7b03f9f4f1c2ccc9b953d8afa4b38819d0ea7e5 --- /dev/null +++ b/gso/migrations/versions/2024-02-27_5bea5647f61d_add_ip_trunk_activation_workflow.py @@ -0,0 +1,39 @@ +"""Add IP Trunk activation workflow. + +Revision ID: 5bea5647f61d +Revises: 113a81d2a40a +Create Date: 2024-02-27 17:01:57.300326 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '5bea5647f61d' +down_revision = '113a81d2a40a' +branch_labels = None +depends_on = None + + +from orchestrator.migrations.helpers import create_workflow, delete_workflow + +new_workflows = [ + { + "name": "activate_iptrunk", + "target": "MODIFY", + "description": "Activate an IP Trunk", + "product_type": "Iptrunk" + } +] + + +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/products/__init__.py b/gso/products/__init__.py index bd721395366659c40907dd13a07fc28ec2fa7d76..2fd25d9fe556299706f618213d06a9ae9f04763e 100644 --- a/gso/products/__init__.py +++ b/gso/products/__init__.py @@ -9,8 +9,10 @@ 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.office_router import OfficeRouter from gso.products.product_types.router import Router from gso.products.product_types.site import Site +from gso.products.product_types.super_pop_switch import SuperPopSwitch class ProductType(strEnum): @@ -19,6 +21,8 @@ class ProductType(strEnum): IP_TRUNK = "IP trunk" ROUTER = "Router" SITE = "Site" + SUPER_POP_SWITCH = "Super PoP switch" + OFFICE_ROUTER = "Office router" SUBSCRIPTION_MODEL_REGISTRY.update( @@ -26,5 +30,7 @@ SUBSCRIPTION_MODEL_REGISTRY.update( "IP trunk": Iptrunk, "Router": Router, "Site": Site, + "Super PoP switch": SuperPopSwitch, + "Office router": OfficeRouter, }, ) diff --git a/gso/products/product_blocks/office_router.py b/gso/products/product_blocks/office_router.py new file mode 100644 index 0000000000000000000000000000000000000000..fec7ad8d16366baf12ec3528748f71aa2fa36d90 --- /dev/null +++ b/gso/products/product_blocks/office_router.py @@ -0,0 +1,56 @@ +"""Product block for :class:`office router` products.""" + +import ipaddress + +from orchestrator.domain.base import ProductBlockModel +from orchestrator.types import SubscriptionLifecycle + +from gso.products.product_blocks.site import ( + SiteBlock, + SiteBlockInactive, + SiteBlockProvisioning, +) +from gso.utils.shared_enums import PortNumber, Vendor + + +class OfficeRouterBlockInactive( + ProductBlockModel, + lifecycle=[SubscriptionLifecycle.INITIAL], + product_block_name="OfficeRouterBlock", +): + """An office router that's being currently inactive. See :class:`OfficeRouterBlock`.""" + + office_router_fqdn: str | None = None + office_router_ts_port: PortNumber | None = None + office_router_lo_ipv4_address: ipaddress.IPv4Address | None = None + office_router_lo_ipv6_address: ipaddress.IPv6Address | None = None + office_router_site: SiteBlockInactive | None + vendor: Vendor | None = None + + +class OfficeRouterBlockProvisioning(OfficeRouterBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): + """An office router that's being provisioned. See :class:`RouterBlock`.""" + + office_router_fqdn: str | None = None + office_router_ts_port: PortNumber | None = None + office_router_lo_ipv4_address: ipaddress.IPv4Address | None = None + office_router_lo_ipv6_address: ipaddress.IPv6Address | None = None + office_router_site: SiteBlockProvisioning | None + vendor: Vendor | None = None + + +class OfficeRouterBlock(OfficeRouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): + """An office router that's currently deployed in the network.""" + + #: Office router FQDN. + office_router_fqdn: str + #: The port of the terminal server that this office router is connected to. Used to offer out of band access. + office_router_ts_port: PortNumber + #: The IPv4 loopback address of the office router. + office_router_lo_ipv4_address: ipaddress.IPv4Address + #: The IPv6 loopback address of the office router. + office_router_lo_ipv6_address: ipaddress.IPv6Address + #: The :class:`Site` that this office router resides in. Both physically and computationally. + office_router_site: SiteBlock + #: The vendor of an office router. Defaults to Juniper. + vendor: Vendor = Vendor.JUNIPER diff --git a/gso/products/product_blocks/router.py b/gso/products/product_blocks/router.py index 5d1a307d85825759b734a0c06b665d1e7eb06866..f91bf1c70507a2f7814bfe69643c70489cb0c4c2 100644 --- a/gso/products/product_blocks/router.py +++ b/gso/products/product_blocks/router.py @@ -4,14 +4,13 @@ import ipaddress from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum -from pydantic import ConstrainedInt -from gso import settings from gso.products.product_blocks.site import ( SiteBlock, SiteBlockInactive, SiteBlockProvisioning, ) +from gso.utils.shared_enums import PortNumber, Vendor class RouterRole(strEnum): @@ -22,23 +21,6 @@ class RouterRole(strEnum): AMT = "amt" -class RouterVendor(strEnum): - """Enumerator for the different product vendors that are supported.""" - - JUNIPER = "juniper" - NOKIA = "nokia" - - -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], @@ -54,13 +36,7 @@ class RouterBlockInactive( router_lo_iso_address: str | None = None router_role: RouterRole | None = None router_site: SiteBlockInactive | None - vendor: RouterVendor | None = None - - -def generate_fqdn(hostname: str, site_name: str, country_code: str) -> str: - """Generate an :term:`FQDN` from a hostname, site name, and a country code.""" - oss = settings.load_oss_params() - return f"{hostname}.{site_name.lower()}.{country_code.lower()}{oss.IPAM.LO.domain_name}" + vendor: Vendor | None = None class RouterBlockProvisioning(RouterBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): @@ -74,7 +50,7 @@ class RouterBlockProvisioning(RouterBlockInactive, lifecycle=[SubscriptionLifecy router_lo_iso_address: str router_role: RouterRole router_site: SiteBlockProvisioning - vendor: RouterVendor + vendor: Vendor class RouterBlock(RouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): @@ -97,4 +73,4 @@ class RouterBlock(RouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTI #: The :class:`Site` that this router resides in. Both physically and computationally. router_site: SiteBlock #: The vendor of a router. - vendor: RouterVendor + vendor: Vendor diff --git a/gso/products/product_blocks/super_pop_switch.py b/gso/products/product_blocks/super_pop_switch.py new file mode 100644 index 0000000000000000000000000000000000000000..af2f2ba74c98cc41806842d9877e8b0168ec3748 --- /dev/null +++ b/gso/products/product_blocks/super_pop_switch.py @@ -0,0 +1,52 @@ +"""Product block for :class:`Super PoP Switch` products.""" + +import ipaddress + +from orchestrator.domain.base import ProductBlockModel +from orchestrator.types import SubscriptionLifecycle + +from gso.products.product_blocks.site import ( + SiteBlock, + SiteBlockInactive, + SiteBlockProvisioning, +) +from gso.utils.shared_enums import PortNumber, Vendor + + +class SuperPopSwitchBlockInactive( + ProductBlockModel, + lifecycle=[SubscriptionLifecycle.INITIAL], + product_block_name="SuperPopSwitchBlock", +): + """A Super PoP switch that's being currently inactive. See :class:`SuperPopSwitchBlock`.""" + + super_pop_switch_fqdn: str | None = None + super_pop_switch_ts_port: PortNumber | None = None + super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address | None = None + super_pop_switch_site: SiteBlockInactive | None + vendor: Vendor | None = None + + +class SuperPopSwitchBlockProvisioning(SuperPopSwitchBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): + """A Super PoP switch that's being provisioned. See :class:`SuperPopSwitchBlock`.""" + + super_pop_switch_fqdn: str | None = None + super_pop_switch_ts_port: PortNumber | None = None + super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address | None = None + super_pop_switch_site: SiteBlockProvisioning | None + vendor: Vendor | None = None + + +class SuperPopSwitchBlock(SuperPopSwitchBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): + """A Super PoP switch that's currently deployed in the network.""" + + #: Super PoP switch FQDN. + super_pop_switch_fqdn: str + #: The port of the terminal server that this Super PoP switch is connected to. Used to offer out of band access. + super_pop_switch_ts_port: PortNumber + #: The IPv4 management address of the Super PoP switch. + super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address + #: The :class:`Site` that this Super PoP switch resides in. Both physically and computationally. + super_pop_switch_site: SiteBlock + #: The vendor of a Super PoP switch. Defaults to Juniper. + vendor: Vendor = Vendor.JUNIPER diff --git a/gso/products/product_types/office_router.py b/gso/products/product_types/office_router.py new file mode 100644 index 0000000000000000000000000000000000000000..6fff33e041edc35e1f333f66a01ac3d6f78fc286 --- /dev/null +++ b/gso/products/product_types/office_router.py @@ -0,0 +1,28 @@ +"""An office router product type.""" + +from orchestrator.domain.base import SubscriptionModel +from orchestrator.types import SubscriptionLifecycle + +from gso.products.product_blocks.office_router import ( + OfficeRouterBlock, + OfficeRouterBlockInactive, + OfficeRouterBlockProvisioning, +) + + +class OfficeRouterInactive(SubscriptionModel, is_base=True): + """An inactive office router.""" + + office_router: OfficeRouterBlockInactive + + +class OfficeRouterProvisioning(OfficeRouterInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): + """An office router that is being provisioned.""" + + office_router: OfficeRouterBlockProvisioning + + +class OfficeRouter(OfficeRouterProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): + """An office router that is currently active.""" + + office_router: OfficeRouterBlock diff --git a/gso/products/product_types/super_pop_switch.py b/gso/products/product_types/super_pop_switch.py new file mode 100644 index 0000000000000000000000000000000000000000..e12b1a3d12793d372c47b5022a85307a7bf730ce --- /dev/null +++ b/gso/products/product_types/super_pop_switch.py @@ -0,0 +1,28 @@ +"""A Super PoP switch product type.""" + +from orchestrator.domain.base import SubscriptionModel +from orchestrator.types import SubscriptionLifecycle + +from gso.products.product_blocks.super_pop_switch import ( + SuperPopSwitchBlock, + SuperPopSwitchBlockInactive, + SuperPopSwitchBlockProvisioning, +) + + +class SuperPopSwitchInactive(SubscriptionModel, is_base=True): + """An inactive Super PoP switch.""" + + super_pop_switch: SuperPopSwitchBlockInactive + + +class SuperPopSwitchProvisioning(SuperPopSwitchInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): + """A Super PoP switch that is being provisioned.""" + + super_pop_switch: SuperPopSwitchBlockProvisioning + + +class SuperPopSwitch(SuperPopSwitchProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): + """A Super PoP switch that is currently active.""" + + super_pop_switch: SuperPopSwitchBlock diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index 6838ad9eada39ad5fe3999f0f28096e04216388a..8c23116a7ef8d4d3124cb89cb212e2a13dcfb9fc 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -65,7 +65,7 @@ def _allocate_network( for network in container.subnets(new_prefix=netmask): if objects.Network.search(conn, network=str(network)) is None: created_net = objects.Network.create( - conn, network=str(network), dns_view=dns_view, network_view=network_view, comment=comment + conn, network=str(network), view=dns_view, network_view=network_view, comment=comment ) if created_net.response != "Infoblox Object already Exists": return ipaddress.ip_network(created_net.network) @@ -202,7 +202,7 @@ def allocate_host( name=hostname, aliases=cname_aliases, comment=comment, - dns_view=dns_view, + view=dns_view, network_view=network_view, ) created_v6 = ipaddress.IPv6Address(new_host.ipv6addr) @@ -264,7 +264,7 @@ def create_host_by_ip( # This needs to be done in two steps, otherwise only one of the IP addresses is stored. objects.HostRecord.create( - conn, ip=ipv6_object, name=hostname, comment=comment, dns_view=dns_view, network_view=network_view + conn, ip=ipv6_object, name=hostname, comment=comment, view=dns_view, network_view=network_view ) new_host = find_host_by_fqdn(hostname) new_host.ipv4addrs = [ipv4_object] diff --git a/gso/services/subscriptions.py b/gso/services/subscriptions.py index 9eb0583e6c74461b7155b7d94538ab1afad4a0e1..e89d9c25ad1cc52f68ae6ac3aea75a0542b92559 100644 --- a/gso/services/subscriptions.py +++ b/gso/services/subscriptions.py @@ -20,23 +20,23 @@ from orchestrator.types import SubscriptionLifecycle from pydantic_forms.types import UUIDstr from gso.products import ProductType +from gso.products.product_types.site import Site SubscriptionType = dict[str, Any] -def get_active_subscriptions( +def get_subscriptions( product_type: str, + lifecycle: SubscriptionLifecycle, includes: list[str] | None = None, excludes: list[str] | None = None, ) -> list[SubscriptionType]: """Retrieve active subscriptions for a specific product type. - :param product_type: The type of the product for which to retrieve subscriptions. - :type product_type: str - :param includes: List of fields to be included in the returned Subscription objects. - :type includes: list[str] - :param excludes: List of fields to be excluded from the returned Subscription objects. - :type excludes: list[str] + :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[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] @@ -51,7 +51,7 @@ def get_active_subscriptions( query = SubscriptionTable.query.join(ProductTable).filter( ProductTable.product_type == product_type, - SubscriptionTable.status == SubscriptionLifecycle.ACTIVE, + SubscriptionTable.status == lifecycle, ) results = query.with_entities(*dynamic_fields).all() @@ -59,9 +59,7 @@ def get_active_subscriptions( return [dict(zip(includes, result, strict=True)) for result in results] -def get_active_site_subscriptions( - includes: list[str] | None = None, -) -> list[SubscriptionType]: +def get_active_site_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: """Retrieve active subscriptions specifically for sites. :param includes: The fields to be included in the returned Subscription objects. @@ -70,12 +68,10 @@ def get_active_site_subscriptions( :return: A list of Subscription objects for sites. :rtype: list[Subscription] """ - return get_active_subscriptions(product_type=ProductType.SITE, includes=includes) + return get_subscriptions(product_type=ProductType.SITE, lifecycle=SubscriptionLifecycle.ACTIVE, includes=includes) -def get_active_router_subscriptions( - includes: list[str] | None = None, -) -> list[SubscriptionType]: +def get_active_router_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: """Retrieve active subscriptions specifically for routers. :param includes: The fields to be included in the returned Subscription objects. @@ -84,12 +80,19 @@ def get_active_router_subscriptions( :return: A list of Subscription objects for routers. :rtype: list[Subscription] """ - return get_active_subscriptions(product_type="Router", includes=includes) + return get_subscriptions(product_type="Router", lifecycle=SubscriptionLifecycle.ACTIVE, includes=includes) -def get_active_iptrunk_subscriptions( - includes: list[str] | None = None, -) -> list[SubscriptionType]: +def get_provisioning_router_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: + """Retrieve provisioning subscriptions specifically for routers. + + :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) + + +def get_active_iptrunk_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: """Retrieve active subscriptions specifically for IP trunks. :param includes: The fields to be included in the returned Subscription objects. @@ -98,7 +101,7 @@ def get_active_iptrunk_subscriptions( :return: A list of Subscription objects for IP trunks. :rtype: list[Subscription] """ - return get_active_subscriptions(product_type="Iptrunk", includes=includes) + return get_subscriptions(product_type="Iptrunk", lifecycle=SubscriptionLifecycle.ACTIVE, includes=includes) def get_active_trunks_that_terminate_on_router(subscription_id: UUIDstr) -> list[SubscriptionTable]: @@ -175,3 +178,17 @@ def count_incomplete_validate_products() -> int: def get_insync_subscriptions() -> list[SubscriptionTable]: """Retrieve all subscriptions that are currently in sync.""" return SubscriptionTable.query.join(ProductTable).filter(SubscriptionTable.insync.is_(True)).all() + + +def get_site_by_name(site_name: str) -> Site: + """Get a site by its name. + + :param site_name: The name of the site. + :type site_name: str + """ + subscription = get_active_subscriptions_by_field_and_value("site_name", site_name) + if not subscription: + msg = f"Site with name {site_name} not found." + raise ValueError(msg) + + return Site.from_subscription(subscription[0].subscription_id) diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json index c8f8d2410f4b94719e127739b2a6e711e01e2efe..c62a6f69aa064bcefe19428e8179ffdbcd24bf2a 100644 --- a/gso/translations/en-GB.json +++ b/gso/translations/en-GB.json @@ -36,6 +36,7 @@ } }, "workflow": { + "activate_router": "Activate router", "confirm_info": "Please verify this form looks correct.", "deploy_twamp": "Deploy TWAMP", "migrate_iptrunk": "Migrate IP Trunk", diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index c43a80737f56e37e3288e78d89a0ab38dced94e4..6c30324ed0b81064bdc4c84e862f1a0ff671b9da 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -12,12 +12,13 @@ from pydantic import BaseModel, validator from pydantic.fields import ModelField from pydantic_forms.validators import Choice +from gso import settings from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock -from gso.products.product_blocks.router import RouterVendor from gso.products.product_blocks.site import SiteTier from gso.products.product_types.router import Router from gso.services.netbox_client import NetboxClient from gso.services.subscriptions import get_active_subscriptions_by_field_and_value +from gso.utils.shared_enums import Vendor class LAGMember(BaseModel): @@ -44,7 +45,7 @@ def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: For Nokia routers, return a list of available interfaces. For Juniper routers, return a string. """ - if get_router_vendor(router_id) != RouterVendor.NOKIA: + if get_router_vendor(router_id) != Vendor.NOKIA: return None interfaces = { interface["name"]: f"{interface['name']} {interface['description']}" @@ -63,7 +64,7 @@ def available_interfaces_choices_including_current_members( For Nokia routers, return a list of available interfaces. For Juniper routers, return a string. """ - if get_router_vendor(router_id) != RouterVendor.NOKIA: + if get_router_vendor(router_id) != Vendor.NOKIA: return None available_interfaces = list(NetboxClient().get_available_interfaces(router_id, speed)) @@ -88,20 +89,20 @@ def available_lags_choices(router_id: UUID) -> Choice | None: For Nokia routers, return a list of available lags. For Juniper routers, return ``None``. """ - if get_router_vendor(router_id) != RouterVendor.NOKIA: + if get_router_vendor(router_id) != Vendor.NOKIA: return None side_a_ae_iface_list = NetboxClient().get_available_lags(router_id) return Choice("ae iface", zip(side_a_ae_iface_list, side_a_ae_iface_list, strict=True)) # type: ignore[arg-type] -def get_router_vendor(router_id: UUID) -> RouterVendor: +def get_router_vendor(router_id: UUID) -> Vendor: """Retrieve the vendor of a router. :param router_id: The :term:`UUID` of the router. :type router_id: :class:`uuid.UUID` :return: The vendor of the router. - :rtype: RouterVendor: + :rtype: Vendor: """ return Router.from_subscription(router_id).router.vendor @@ -130,7 +131,7 @@ def validate_router_in_netbox(subscription_id: UUIDstr) -> UUIDstr: :rtype: :class:`UUIDstr` """ router_type = Router.from_subscription(subscription_id) - if router_type.router.vendor == RouterVendor.NOKIA: + if router_type.router.vendor == Vendor.NOKIA: device = NetboxClient().get_device_by_name(router_type.router.router_fqdn) if not device: msg = "The selected router does not exist in Netbox." @@ -259,7 +260,7 @@ def validate_interface_name_list(interface_name_list: list, vendor: str) -> list exception. """ # For Nokia nothing to do - if vendor == RouterVendor.NOKIA: + if vendor == Vendor.NOKIA: return interface_name_list pattern = re.compile(r"^(ge|et|xe)-[0-9]/[0-9]/[0-9]$") for interface in interface_name_list: @@ -290,3 +291,9 @@ def validate_tt_number(tt_number: str) -> str: raise ValueError(err_msg) return tt_number + + +def generate_fqdn(hostname: str, site_name: str, country_code: str) -> str: + """Generate an :term:`FQDN` from a hostname, site name, and a country code.""" + oss = settings.load_oss_params() + return f"{hostname}.{site_name.lower()}.{country_code.lower()}{oss.IPAM.LO.domain_name}" diff --git a/gso/utils/shared_enums.py b/gso/utils/shared_enums.py new file mode 100644 index 0000000000000000000000000000000000000000..4861e9e134ecf113d74772a08282e7928829d19b --- /dev/null +++ b/gso/utils/shared_enums.py @@ -0,0 +1,21 @@ +"""Shared choices for the different models.""" + +from pydantic import ConstrainedInt +from pydantic_forms.types import strEnum + + +class Vendor(strEnum): + """Enumerator for the different product vendors that are supported.""" + + JUNIPER = "juniper" + NOKIA = "nokia" + + +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/workflows/__init__.py b/gso/workflows/__init__.py index 56f8a191946a79a786bc8764f70c03c77d9abd1b..d25088730e93388aadef15057e80d3eca8a93ce6 100644 --- a/gso/workflows/__init__.py +++ b/gso/workflows/__init__.py @@ -1,13 +1,27 @@ """Initialisation class that imports all workflows into :term:`GSO`.""" +from orchestrator.services.subscriptions import WF_USABLE_MAP from orchestrator.workflows import LazyWorkflowInstance +WF_USABLE_MAP.update( + { + "redeploy_base_config": ["provisioning", "active"], + "update_ibgp_mesh": ["provisioning", "active"], + "activate_router": ["provisioning"], + "deploy_twamp": ["provisioning", "active"], + "modify_trunk_interface": ["provisioning", "active"], + "activate_iptrunk": ["provisioning"], + } +) + +LazyWorkflowInstance("gso.workflows.iptrunk.activate_iptrunk", "activate_iptrunk") LazyWorkflowInstance("gso.workflows.iptrunk.create_iptrunk", "create_iptrunk") LazyWorkflowInstance("gso.workflows.iptrunk.deploy_twamp", "deploy_twamp") LazyWorkflowInstance("gso.workflows.iptrunk.modify_isis_metric", "modify_isis_metric") LazyWorkflowInstance("gso.workflows.iptrunk.modify_trunk_interface", "modify_trunk_interface") LazyWorkflowInstance("gso.workflows.iptrunk.migrate_iptrunk", "migrate_iptrunk") LazyWorkflowInstance("gso.workflows.iptrunk.terminate_iptrunk", "terminate_iptrunk") +LazyWorkflowInstance("gso.workflows.router.activate_router", "activate_router") 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") @@ -18,3 +32,5 @@ LazyWorkflowInstance("gso.workflows.site.terminate_site", "terminate_site") LazyWorkflowInstance("gso.workflows.tasks.import_site", "import_site") LazyWorkflowInstance("gso.workflows.tasks.import_router", "import_router") LazyWorkflowInstance("gso.workflows.tasks.import_iptrunk", "import_iptrunk") +LazyWorkflowInstance("gso.workflows.tasks.import_super_pop_switch", "import_super_pop_switch") +LazyWorkflowInstance("gso.workflows.tasks.import_office_router", "import_office_router") diff --git a/gso/workflows/iptrunk/activate_iptrunk.py b/gso/workflows/iptrunk/activate_iptrunk.py new file mode 100644 index 0000000000000000000000000000000000000000..f686a8cb7e3c825dceffeb876c644a37342ce3d8 --- /dev/null +++ b/gso/workflows/iptrunk/activate_iptrunk.py @@ -0,0 +1,59 @@ +"""Activate IP trunk takes a provisioning trunk to the active lifecycle state.""" + +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, SubscriptionLifecycle, UUIDstr +from orchestrator.workflow import StepList, done, init, inputstep, workflow +from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync +from orchestrator.workflows.utils import wrap_modify_initial_input_form + +from gso.products.product_types.iptrunk import Iptrunk + + +def _initial_input_form(subscription_id: UUIDstr) -> FormGenerator: + trunk = Iptrunk.from_subscription(subscription_id) + + class ActivateTrunkForm(FormPage): + info_label: Label = "Start approval process for IP trunk activation." # type:ignore[assignment] + + user_input = yield ActivateTrunkForm + + return user_input.dict() | {"subscription": trunk} + + +@inputstep("Verify checklist completion", assignee=Assignee.SYSTEM) +def verify_complete_checklist() -> FormGenerator: + """Show a form for the operator to input a link to the completed checklist.""" + + class VerifyCompleteForm(FormPage): + info_label: Label = "Verify that the checklist has been completed. Then continue this workflow." # type: ignore[assignment] + checklist_url: str = "" + + user_input = yield VerifyCompleteForm + + return {"checklist_url": user_input.dict()["checklist_url"]} + + +@workflow( + "Activate an IP Trunk", + initial_input_form=wrap_modify_initial_input_form(_initial_input_form), + target=Target.MODIFY, +) +def activate_iptrunk() -> StepList: + """Move an IP Trunk from a ``PROVISIONING`` state to an ``ACTIVE`` state. + + * Send email notifications to different teams. + * Wait for approval to be given. + * Update the subscription lifecycle state to ``ACTIVE``. + """ + return ( + init + >> store_process_subscription(Target.MODIFY) + >> unsync + >> verify_complete_checklist + >> set_status(SubscriptionLifecycle.ACTIVE) + >> resync + >> done + ) diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index db6f91ce99d706d1c97d59bbda15785532f36d50..e9e304d224c5a9c36314a21050bf878846c8ef70 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -21,7 +21,6 @@ from gso.products.product_blocks.iptrunk import ( IptrunkType, PhyPortCapacity, ) -from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import IptrunkInactive from gso.products.product_types.router import Router from gso.services import infoblox, subscriptions @@ -38,12 +37,16 @@ from gso.utils.helpers import ( validate_router_in_netbox, validate_tt_number, ) +from gso.utils.shared_enums import Vendor def initial_input_form_generator(product_name: str) -> FormGenerator: """Gather input from the user in three steps. General information, and information on both sides of the trunk.""" routers = {} - for router in subscriptions.get_active_router_subscriptions(includes=["subscription_id", "description"]): + for router in subscriptions.get_active_router_subscriptions( + includes=["subscription_id", "description"] + ) + subscriptions.get_provisioning_router_subscriptions(includes=["subscription_id", "description"]): + # Add both provisioning and active routers, since trunks are required for promoting a router to active. routers[str(router["subscription_id"])] = router["description"] class CreateIptrunkForm(FormPage): @@ -82,7 +85,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: class JuniperAeMembers(UniqueConstrainedList[LAGMember]): min_items = initial_user_input.iptrunk_minimum_links - if get_router_vendor(router_a) == RouterVendor.NOKIA: + if get_router_vendor(router_a) == Vendor.NOKIA: class NokiaLAGMemberA(LAGMember): interface_name: available_interfaces_choices( # type: ignore[valid-type] @@ -130,7 +133,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: user_input_router_side_b = yield SelectRouterSideB router_b = user_input_router_side_b.side_b_node_id.name - if get_router_vendor(router_b) == RouterVendor.NOKIA: + if get_router_vendor(router_b) == Vendor.NOKIA: class NokiaLAGMemberB(LAGMember): interface_name: available_interfaces_choices( # type: ignore[valid-type] @@ -400,7 +403,7 @@ def reserve_interfaces_in_netbox(subscription: IptrunkInactive) -> State: """Create the :term:`LAG` interfaces in NetBox and attach the lag interfaces to the physical interfaces.""" nbclient = NetboxClient() for trunk_side in subscription.iptrunk.iptrunk_sides: - if get_router_vendor(trunk_side.iptrunk_side_node.owner_subscription_id) == RouterVendor.NOKIA: + if get_router_vendor(trunk_side.iptrunk_side_node.owner_subscription_id) == Vendor.NOKIA: # Create :term:`LAG` interfaces lag_interface: Interfaces = nbclient.create_interface( iface_name=trunk_side.iptrunk_side_ae_iface, @@ -469,8 +472,8 @@ def create_iptrunk() -> StepList: * Allocate the interfaces in Netbox * Set the subscription to active in the database """ - side_a_is_nokia = conditional(lambda state: get_router_vendor(state["side_a_node_id"]) == RouterVendor.NOKIA) - side_b_is_nokia = conditional(lambda state: get_router_vendor(state["side_b_node_id"]) == RouterVendor.NOKIA) + side_a_is_nokia = conditional(lambda state: get_router_vendor(state["side_a_node_id"]) == Vendor.NOKIA) + side_b_is_nokia = conditional(lambda state: get_router_vendor(state["side_b_node_id"]) == Vendor.NOKIA) return ( init diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 71729149bf86db868b0bfb477565120ff8e4d98d..5f1240c56e74c0b16fe76bec4a72b962fbbec7c3 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -25,7 +25,6 @@ from pydantic_forms.core import ReadOnlyField from pynetbox.models.dcim import Interfaces from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock -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 import infoblox @@ -41,6 +40,7 @@ from gso.utils.helpers import ( validate_interface_name_list, validate_tt_number, ) +from gso.utils.shared_enums import Vendor from gso.utils.workflow_steps import set_isis_to_90000 @@ -109,7 +109,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: new_router = new_side_iptrunk_router_input.new_node side_a_ae_iface = available_lags_choices(new_router) or str - new_side_is_nokia = get_router_vendor(new_router) == RouterVendor.NOKIA + new_side_is_nokia = get_router_vendor(new_router) == Vendor.NOKIA if new_side_is_nokia: class NokiaLAGMember(LAGMember): @@ -155,7 +155,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @validator("new_lag_interface", allow_reuse=True, pre=True, always=True) def lag_interface_proper_name(cls, new_lag_interface: str) -> str: - if get_router_vendor(new_router) == RouterVendor.JUNIPER: + if get_router_vendor(new_router) == Vendor.JUNIPER: juniper_lag_re = re.compile("^ae\\d{1,2}$") if not juniper_lag_re.match(new_lag_interface): msg = "Invalid LAG name, please try again." @@ -643,10 +643,10 @@ def migrate_iptrunk() -> StepList: * Update the subscription model in the database * Update the reserved interfaces in Netbox """ - new_side_is_nokia = conditional(lambda state: get_router_vendor(state["new_node"]) == RouterVendor.NOKIA) + new_side_is_nokia = conditional(lambda state: get_router_vendor(state["new_node"]) == Vendor.NOKIA) old_side_is_nokia = conditional( lambda state: get_router_vendor(state["old_side_data"]["iptrunk_side_node"]["owner_subscription_id"]) - == RouterVendor.NOKIA + == Vendor.NOKIA ) should_restore_isis_metric = conditional(lambda state: state["restore_isis_metric"]) diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 1dcda4fd5ceb2f3b2a4114533ff313a76823ac53..d4e6edb19160c77a429f5a7f307b5897ec5df055 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -21,7 +21,6 @@ from gso.products.product_blocks.iptrunk import ( IptrunkType, PhyPortCapacity, ) -from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import Iptrunk from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import execute_playbook, pp_interaction @@ -34,6 +33,7 @@ from gso.utils.helpers import ( validate_iptrunk_unique_interface, validate_tt_number, ) +from gso.utils.shared_enums import Vendor def initialize_ae_members(subscription: Iptrunk, initial_user_input: dict, side_index: int) -> type[LAGMember]: @@ -41,7 +41,7 @@ def initialize_ae_members(subscription: Iptrunk, initial_user_input: dict, side_ router = subscription.iptrunk.iptrunk_sides[side_index].iptrunk_side_node router_vendor = get_router_vendor(router.owner_subscription_id) iptrunk_minimum_link = initial_user_input["iptrunk_minimum_links"] - if router_vendor == RouterVendor.NOKIA: + if router_vendor == Vendor.NOKIA: iptrunk_speed = initial_user_input["iptrunk_speed"] class NokiaLAGMember(LAGMember): @@ -386,13 +386,13 @@ def modify_trunk_interface() -> StepList: lambda state: get_router_vendor( state["subscription"]["iptrunk"]["iptrunk_sides"][0]["iptrunk_side_node"]["owner_subscription_id"] ) - == RouterVendor.NOKIA + == Vendor.NOKIA ) side_b_is_nokia = conditional( lambda state: get_router_vendor( state["subscription"]["iptrunk"]["iptrunk_sides"][1]["iptrunk_side_node"]["owner_subscription_id"] ) - == RouterVendor.NOKIA + == Vendor.NOKIA ) return ( init diff --git a/gso/workflows/iptrunk/terminate_iptrunk.py b/gso/workflows/iptrunk/terminate_iptrunk.py index 33ee86fde6129a05278d8985a3959e2ef945c067..330c90c3fddb35253180b8668d61947f484ce9b7 100644 --- a/gso/workflows/iptrunk/terminate_iptrunk.py +++ b/gso/workflows/iptrunk/terminate_iptrunk.py @@ -19,12 +19,12 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form from pydantic import validator from gso.products.product_blocks.iptrunk import IptrunkSideBlock -from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import Iptrunk from gso.services import infoblox from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import execute_playbook, pp_interaction from gso.utils.helpers import get_router_vendor, validate_tt_number +from gso.utils.shared_enums import Vendor from gso.utils.workflow_steps import set_isis_to_90000 @@ -159,14 +159,14 @@ def terminate_iptrunk() -> StepList: and get_router_vendor( state["subscription"]["iptrunk"]["iptrunk_sides"][0]["iptrunk_side_node"]["owner_subscription_id"] ) - == RouterVendor.NOKIA + == Vendor.NOKIA ) side_b_is_nokia = conditional( lambda state: state["clean_up_netbox"] and get_router_vendor( state["subscription"]["iptrunk"]["iptrunk_sides"][1]["iptrunk_side_node"]["owner_subscription_id"] ) - == RouterVendor.NOKIA + == Vendor.NOKIA ) config_steps = ( diff --git a/gso/workflows/router/activate_router.py b/gso/workflows/router/activate_router.py new file mode 100644 index 0000000000000000000000000000000000000000..4de880f4e2b8a9cc13b7c1c80315fef634e577c9 --- /dev/null +++ b/gso/workflows/router/activate_router.py @@ -0,0 +1,59 @@ +"""Activate router takes a provisioning router to the active lifecycle state.""" + +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, SubscriptionLifecycle, UUIDstr +from orchestrator.workflow import StepList, done, init, inputstep, workflow +from orchestrator.workflows.steps import resync, set_status, store_process_subscription, unsync +from orchestrator.workflows.utils import wrap_modify_initial_input_form + +from gso.products.product_types.router import Router + + +def _initial_input_form(subscription_id: UUIDstr) -> FormGenerator: + router = Router.from_subscription(subscription_id) + + class ActivateRouterForm(FormPage): + info_label: Label = "Start approval process for router activation." # type:ignore[assignment] + + user_input = yield ActivateRouterForm + + return user_input.dict() | {"subscription": router} + + +@inputstep("Verify checklist completion", assignee=Assignee.SYSTEM) +def verify_complete_checklist() -> FormGenerator: + """Show a form for the operator to input a link to the completed checklist.""" + + class VerifyCompleteForm(FormPage): + info_label: Label = "Verify that the checklist has been completed. Then continue this workflow." # type: ignore[assignment] + checklist_url: str = "" + + user_input = yield VerifyCompleteForm + + return {"checklist_url": user_input.dict()["checklist_url"]} + + +@workflow( + "Activate a router", + initial_input_form=wrap_modify_initial_input_form(_initial_input_form), + target=Target.MODIFY, +) +def activate_router() -> StepList: + """Move a router from a ``PROVISIONING`` state to an ``ACTIVE`` state. + + * Send email notifications to different teams. + * Wait for approval to be given. + * Update the subscription lifecycle state to ``ACTIVE``. + """ + return ( + init + >> store_process_subscription(Target.MODIFY) + >> unsync + >> verify_complete_checklist + >> set_status(SubscriptionLifecycle.ACTIVE) + >> resync + >> done + ) diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 29a60dbb87d73f68f695710d3b746a8e8fa4c198..f33dcec56f8314c2ebd188f7e5d9df6865caa032 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -13,19 +13,15 @@ from orchestrator.workflows.utils import wrap_create_initial_input_form from pydantic import validator from pydantic_forms.core import ReadOnlyField -from gso.products.product_blocks.router import ( - PortNumber, - RouterRole, - RouterVendor, - generate_fqdn, -) +from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import RouterInactive from gso.products.product_types.site import Site from gso.services import infoblox, subscriptions from gso.services.crm import get_customer_by_name from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import pp_interaction -from gso.utils.helpers import iso_from_ipv4 +from gso.utils.helpers import generate_fqdn, iso_from_ipv4 +from gso.utils.shared_enums import PortNumber, Vendor from gso.utils.workflow_steps import deploy_base_config_dry, deploy_base_config_real, run_checks_after_base_config @@ -47,7 +43,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: tt_number: str customer: str = ReadOnlyField("GÉANT") - vendor: RouterVendor + vendor: Vendor router_site: _site_selector() # type: ignore[valid-type] hostname: str ts_port: PortNumber @@ -91,7 +87,7 @@ def initialize_subscription( ts_port: PortNumber, router_site: str, router_role: RouterRole, - vendor: RouterVendor, + vendor: Vendor, ) -> State: """Initialise the subscription object in the service database.""" subscription.router.router_ts_port = ts_port @@ -221,7 +217,7 @@ def create_router() -> StepList: * Validate :term:`IPAM` resources * Create a new device in Netbox """ - router_is_nokia = conditional(lambda state: state["vendor"] == RouterVendor.NOKIA) + router_is_nokia = conditional(lambda state: state["vendor"] == Vendor.NOKIA) return ( init diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py index 1a83943a76852689ed931da4f22ea7f172022cfd..20ac88dd053c7d231cdf8445d186f02ee81fa2f8 100644 --- a/gso/workflows/router/terminate_router.py +++ b/gso/workflows/router/terminate_router.py @@ -18,11 +18,11 @@ from orchestrator.workflows.steps import ( ) from orchestrator.workflows.utils import wrap_modify_initial_input_form -from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.router import Router from gso.services import infoblox from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import execute_playbook, pp_interaction +from gso.utils.shared_enums import Vendor logger = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: clean_up_ipam: bool = True user_input = yield TerminateForm - return user_input.dict() | {"router_is_nokia": router.router.vendor == RouterVendor.NOKIA} + return user_input.dict() | {"router_is_nokia": router.router.vendor == Vendor.NOKIA} @step("Deprovision loopback IPs from IPAM") diff --git a/gso/workflows/tasks/import_office_router.py b/gso/workflows/tasks/import_office_router.py new file mode 100644 index 0000000000000000000000000000000000000000..9b8de86fa099e328971b2d052098839f6f66ff1b --- /dev/null +++ b/gso/workflows/tasks/import_office_router.py @@ -0,0 +1,94 @@ +"""A creation workflow that adds existing office routers to the coreDB.""" + +import ipaddress + +from orchestrator import workflow +from orchestrator.forms import FormPage +from orchestrator.targets import Target +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_types import office_router +from gso.products.product_types.office_router import OfficeRouterInactive +from gso.services import subscriptions +from gso.services.crm import get_customer_by_name +from gso.services.subscriptions import get_site_by_name +from gso.utils.shared_enums import PortNumber, Vendor + + +@step("Create subscription") +def create_subscription(customer: str) -> State: + """Create a new subscription object.""" + customer_id = get_customer_by_name(customer)["id"] + product_id = subscriptions.get_product_id_by_name(ProductType.OFFICE_ROUTER) + subscription = OfficeRouterInactive.from_product_id(product_id, customer_id) + + return { + "subscription": subscription, + "subscription_id": subscription.subscription_id, + } + + +def initial_input_form_generator() -> FormGenerator: + """Generate a form that is filled in using information passed through the :term:`API` endpoint.""" + + class ImportOfficeRouter(FormPage): + class Config: + title = "Import an office router" + + customer: str + office_router_site: str + office_router_fqdn: str + office_router_ts_port: PortNumber + office_router_lo_ipv4_address: ipaddress.IPv4Address + office_router_lo_ipv6_address: ipaddress.IPv6Address + + user_input = yield ImportOfficeRouter + + return user_input.dict() + + +@step("Initialize subscription") +def initialize_subscription( + subscription: OfficeRouterInactive, + office_router_fqdn: str, + office_router_ts_port: PortNumber, + office_router_site: str, + office_router_lo_ipv4_address: ipaddress.IPv4Address | None = None, + office_router_lo_ipv6_address: ipaddress.IPv6Address | None = None, +) -> State: + """Initialise the office router subscription using input data.""" + subscription.office_router.office_router_ts_port = office_router_ts_port + site_obj = get_site_by_name(office_router_site).site + subscription.office_router.office_router_site = site_obj + subscription.office_router.office_router_fqdn = office_router_fqdn + subscription.description = f"Office router {office_router_fqdn}" + subscription.office_router.office_router_lo_ipv4_address = office_router_lo_ipv4_address + subscription.office_router.office_router_lo_ipv6_address = office_router_lo_ipv6_address + subscription.office_router.vendor = Vendor.JUNIPER + + subscription = office_router.OfficeRouterProvisioning.from_other_lifecycle( + subscription, SubscriptionLifecycle.PROVISIONING + ) + + return {"subscription": subscription} + + +@workflow( + "Import office router", + initial_input_form=initial_input_form_generator, + target=Target.CREATE, +) +def import_office_router() -> StepList: + """Import an office router without provisioning it.""" + return ( + init + >> create_subscription + >> store_process_subscription(Target.CREATE) + >> initialize_subscription + >> set_status(SubscriptionLifecycle.ACTIVE) + >> resync + >> done + ) diff --git a/gso/workflows/tasks/import_router.py b/gso/workflows/tasks/import_router.py index 878b538a40b0cf326380863d7008ef44b8acaab3..8346b973d51bf959c3397f5df73e0285a9474d4d 100644 --- a/gso/workflows/tasks/import_router.py +++ b/gso/workflows/tasks/import_router.py @@ -11,26 +11,14 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc from gso.products import ProductType from gso.products.product_blocks import router as router_pb -from gso.products.product_blocks.router import PortNumber, RouterRole, RouterVendor, generate_fqdn +from gso.products.product_blocks.router import RouterRole 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.services import subscriptions from gso.services.crm import get_customer_by_name - - -def _get_site_by_name(site_name: str) -> Site: - """Get a site by its name. - - :param site_name: The name of the site. - :type site_name: str - """ - subscription = subscriptions.get_active_subscriptions_by_field_and_value("site_name", site_name) - if not subscription: - msg = f"Site with name {site_name} not found." - raise ValueError(msg) - - return Site.from_subscription(subscription[0].subscription_id) +from gso.services.subscriptions import get_site_by_name +from gso.utils.helpers import generate_fqdn +from gso.utils.shared_enums import PortNumber, Vendor @step("Create subscription") @@ -57,7 +45,7 @@ def initial_input_form_generator() -> FormGenerator: router_site: str hostname: str ts_port: int - router_vendor: RouterVendor + router_vendor: Vendor router_role: RouterRole router_lo_ipv4_address: ipaddress.IPv4Address router_lo_ipv6_address: ipaddress.IPv6Address @@ -75,14 +63,14 @@ def initialize_subscription( ts_port: PortNumber, router_site: str, router_role: router_pb.RouterRole, - router_vendor: RouterVendor, + router_vendor: Vendor, router_lo_ipv4_address: ipaddress.IPv4Address | None = None, router_lo_ipv6_address: ipaddress.IPv6Address | None = None, router_lo_iso_address: str | None = None, ) -> State: """Initialise the router subscription using input data.""" subscription.router.router_ts_port = ts_port - router_site_obj = _get_site_by_name(router_site).site + router_site_obj = get_site_by_name(router_site).site subscription.router.router_site = router_site_obj fqdn = generate_fqdn(hostname, router_site_obj.site_name, router_site_obj.site_country_code) subscription.router.router_fqdn = fqdn diff --git a/gso/workflows/tasks/import_super_pop_switch.py b/gso/workflows/tasks/import_super_pop_switch.py new file mode 100644 index 0000000000000000000000000000000000000000..5d7aa4ab214f630e4db5fa206fc8650681016df1 --- /dev/null +++ b/gso/workflows/tasks/import_super_pop_switch.py @@ -0,0 +1,93 @@ +"""A creation workflow that adds existing Super PoP switches to the coreDB.""" + +import ipaddress + +from orchestrator import workflow +from orchestrator.forms import FormPage +from orchestrator.targets import Target +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_types import super_pop_switch +from gso.products.product_types.super_pop_switch import SuperPopSwitchInactive +from gso.services import subscriptions +from gso.services.crm import get_customer_by_name +from gso.services.subscriptions import get_site_by_name +from gso.utils.helpers import generate_fqdn +from gso.utils.shared_enums import PortNumber, Vendor + + +@step("Create subscription") +def create_subscription(customer: str) -> State: + """Create a new subscription object.""" + customer_id = get_customer_by_name(customer)["id"] + product_id = subscriptions.get_product_id_by_name(ProductType.SUPER_POP_SWITCH) + subscription = SuperPopSwitchInactive.from_product_id(product_id, customer_id) + + return { + "subscription": subscription, + "subscription_id": subscription.subscription_id, + } + + +def initial_input_form_generator() -> FormGenerator: + """Generate a form that is filled in using information passed through the :term:`API` endpoint.""" + + class ImportSuperPopSwitch(FormPage): + class Config: + title = "Import a Super PoP switch" + + customer: str + super_pop_switch_site: str + hostname: str + super_pop_switch_ts_port: PortNumber + super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address + + user_input = yield ImportSuperPopSwitch + + return user_input.dict() + + +@step("Initialize subscription") +def initialize_subscription( + subscription: SuperPopSwitchInactive, + hostname: str, + super_pop_switch_ts_port: PortNumber, + super_pop_switch_site: str, + super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address | None = None, +) -> State: + """Initialise the Super PoP switch subscription using input data.""" + subscription.super_pop_switch.super_pop_switch_ts_port = super_pop_switch_ts_port + site_obj = get_site_by_name(super_pop_switch_site).site + subscription.super_pop_switch.super_pop_switch_site = site_obj + fqdn = generate_fqdn(hostname, site_obj.site_name, site_obj.site_country_code) + subscription.super_pop_switch.super_pop_switch_fqdn = fqdn + subscription.description = f"Super PoP switch {fqdn}" + subscription.super_pop_switch.super_pop_switch_mgmt_ipv4_address = super_pop_switch_mgmt_ipv4_address + subscription.super_pop_switch.vendor = Vendor.JUNIPER + + subscription = super_pop_switch.SuperPopSwitchProvisioning.from_other_lifecycle( + subscription, SubscriptionLifecycle.PROVISIONING + ) + + return {"subscription": subscription} + + +@workflow( + "Import Super PoP switch", + initial_input_form=initial_input_form_generator, + target=Target.CREATE, +) +def import_super_pop_switch() -> StepList: + """Import a Super PoP switch without provisioning it.""" + return ( + init + >> create_subscription + >> store_process_subscription(Target.CREATE) + >> initialize_subscription + >> set_status(SubscriptionLifecycle.ACTIVE) + >> resync + >> done + ) diff --git a/setup.py b/setup.py index cf34f158746312524a27348396cf48a46812080d..5be922344ae8716f0bbbfcd91f73e60b3912fbee 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="0.7", + version="0.8", author="GÉANT", author_email="swd@geant.org", description="GÉANT Service Orchestrator", diff --git a/test/api/test_imports.py b/test/api/test_imports.py index d823bc367d39c3072faf1c307d4c8d796151de22..b4d58f86cef94b2992973cc487fcede995819f14 100644 --- a/test/api/test_imports.py +++ b/test/api/test_imports.py @@ -6,13 +6,16 @@ from orchestrator.db import SubscriptionTable from orchestrator.services import subscriptions from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity -from gso.products.product_blocks.router import RouterRole, RouterVendor +from gso.products.product_blocks.router import RouterRole from gso.products.product_blocks.site import SiteTier from gso.utils.helpers import iso_from_ipv4 +from gso.utils.shared_enums import Vendor SITE_IMPORT_ENDPOINT = "/api/v1/imports/sites" ROUTER_IMPORT_ENDPOINT = "/api/v1/imports/routers" IPTRUNK_IMPORT_API_URL = "/api/v1/imports/iptrunks" +SUPER_POP_SWITCH_IMPORT_API_URL = "/api/v1/imports/super-pop-switches" +OFFICE_ROUTER_IMPORT_API_URL = "/api/v1/imports/office-routers" @pytest.fixture() @@ -114,7 +117,7 @@ def router_data(faker, site_data): return { "hostname": "127.0.0.1", "router_role": RouterRole.PE, - "router_vendor": RouterVendor.JUNIPER, + "router_vendor": Vendor.JUNIPER, "router_site": site_data["site_name"], "ts_port": 1234, "customer": "GÉANT", @@ -124,6 +127,30 @@ def router_data(faker, site_data): } +@pytest.fixture() +def super_pop_switch_data(faker, site_data): + mock_ipv4 = faker.ipv4() + return { + "hostname": "127.0.0.1", + "super_pop_switch_site": site_data["site_name"], + "super_pop_switch_ts_port": 1234, + "customer": "GÉANT", + "super_pop_switch_mgmt_ipv4_address": mock_ipv4, + } + + +@pytest.fixture() +def office_router_data(faker, site_data): + return { + "office_router_fqdn": "127.0.0.1", + "office_router_site": site_data["site_name"], + "office_router_ts_port": 1234, + "customer": "GÉANT", + "office_router_lo_ipv4_address": faker.ipv4(), + "office_router_lo_ipv6_address": faker.ipv6(), + } + + def test_import_site_endpoint(test_client, site_data): assert SubscriptionTable.query.all() == [] # Post data to the endpoint @@ -337,3 +364,59 @@ def test_import_iptrunk_fails_on_side_a_and_b_members_mismatch( }, ], } + + +def test_import_super_pop_switch_endpoint(test_client, site_data, super_pop_switch_data): + response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data) + assert response.status_code == 201 + assert SubscriptionTable.query.count() == 1 + + response = test_client.post(SUPER_POP_SWITCH_IMPORT_API_URL, json=super_pop_switch_data) + assert response.status_code == 201 + assert SubscriptionTable.query.count() == 2 + + +def test_import_super_pop_switch_endpoint_with_invalid_data(test_client, site_data, super_pop_switch_data): + response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data) + assert response.status_code == 201 + assert SubscriptionTable.query.count() == 1 + + # invalid data, missing hostname and invalid mgmt_ipv4_address + super_pop_switch_data.pop("hostname") + super_pop_switch_data["super_pop_switch_mgmt_ipv4_address"] = "invalid" + response = test_client.post(SUPER_POP_SWITCH_IMPORT_API_URL, json=super_pop_switch_data) + assert response.status_code == 422 + assert SubscriptionTable.query.count() == 1 + response = response.json() + assert response["detail"][0]["loc"] == ["body", "hostname"] + assert response["detail"][0]["msg"] == "field required" + assert response["detail"][1]["loc"] == ["body", "super_pop_switch_mgmt_ipv4_address"] + assert response["detail"][1]["msg"] == "value is not a valid IPv4 address" + + +def test_import_office_router_endpoint(test_client, site_data, office_router_data): + response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data) + assert response.status_code == 201 + assert SubscriptionTable.query.count() == 1 + + response = test_client.post(OFFICE_ROUTER_IMPORT_API_URL, json=office_router_data) + assert response.status_code == 201 + assert SubscriptionTable.query.count() == 2 + + +def test_import_office_router_endpoint_with_invalid_data(test_client, site_data, office_router_data): + response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data) + assert response.status_code == 201 + assert SubscriptionTable.query.count() == 1 + + # invalid data, missing FQDN and invalid lo_ipv6_address + office_router_data.pop("office_router_fqdn") + office_router_data["office_router_lo_ipv6_address"] = "invalid" + response = test_client.post(OFFICE_ROUTER_IMPORT_API_URL, json=office_router_data) + assert response.status_code == 422 + assert SubscriptionTable.query.count() == 1 + response = response.json() + assert response["detail"][0]["loc"] == ["body", "office_router_fqdn"] + assert response["detail"][0]["msg"] == "field required" + assert response["detail"][1]["loc"] == ["body", "office_router_lo_ipv6_address"] + assert response["detail"][1]["msg"] == "value is not a valid IPv6 address" diff --git a/test/fixtures.py b/test/fixtures.py index bc96381bfa605c3934610eac8490fa275e6051de..732439527e0b34f346488fae687dfff98a80a577 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -12,12 +12,13 @@ from gso.products.product_blocks.iptrunk import ( IptrunkType, PhyPortCapacity, ) -from gso.products.product_blocks.router import RouterRole, RouterVendor +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.router import Router, RouterInactive from gso.products.product_types.site import Site, SiteInactive from gso.services import subscriptions +from gso.utils.shared_enums import Vendor CUSTOMER_ID: UUIDstr = "2f47f65a-0911-e511-80d0-005056956c1a" @@ -107,7 +108,7 @@ def nokia_router_subscription_factory(site_subscription_factory, faker): router_subscription.router.router_lo_iso_address = router_lo_iso_address router_subscription.router.router_role = router_role router_subscription.router.router_site = Site.from_subscription(router_site).site - router_subscription.router.vendor = RouterVendor.NOKIA + router_subscription.router.vendor = Vendor.NOKIA router_subscription = SubscriptionModel.from_other_lifecycle(router_subscription, SubscriptionLifecycle.ACTIVE) router_subscription.description = description @@ -158,7 +159,7 @@ def juniper_router_subscription_factory(site_subscription_factory, faker): router_subscription.router.router_lo_iso_address = router_lo_iso_address router_subscription.router.router_role = router_role router_subscription.router.router_site = Site.from_subscription(router_site).site - router_subscription.router.vendor = RouterVendor.JUNIPER + router_subscription.router.vendor = Vendor.JUNIPER router_subscription = SubscriptionModel.from_other_lifecycle(router_subscription, SubscriptionLifecycle.ACTIVE) router_subscription.description = description @@ -226,6 +227,7 @@ def iptrunk_subscription_factory(iptrunk_side_subscription_factory, faker): iptrunk_ipv4_network=None, iptrunk_ipv6_network=None, iptrunk_sides=None, + status: SubscriptionLifecycle | None = None, ) -> UUIDstr: product_id = subscriptions.get_product_id_by_name(ProductType.IP_TRUNK) description = description or faker.sentence() @@ -255,6 +257,10 @@ def iptrunk_subscription_factory(iptrunk_side_subscription_factory, faker): iptrunk_subscription, SubscriptionLifecycle.ACTIVE, ) + + if status: + iptrunk_subscription.status = status + iptrunk_subscription.description = description iptrunk_subscription.start_date = start_date iptrunk_subscription.save() diff --git a/test/services/test_infoblox.py b/test/services/test_infoblox.py index 64bf43fdb5917d53a1e06cc95405ce34b0f0bdd0..bebcdae602f852f4fb92fab4bfbeab85e7f12d26 100644 --- a/test/services/test_infoblox.py +++ b/test/services/test_infoblox.py @@ -53,8 +53,8 @@ def _set_up_host_responses(): responses.add( method=responses.GET, - url="https://10.0.0.1/wapi/v2.12/record%3Ahost?name=test.lo.geant.net&ipv6addr=func%3Anextavailableip%3Adead%3A" - "beef%3A%3A%2F80%2Cdefault", + url="https://10.0.0.1/wapi/v2.12/record%3Ahost?name=test.lo.geant.net&view=default&ipv6addr=func%3Anextavailabl" + "eip%3Adead%3Abeef%3A%3A%2F80%2Cdefault", json=[], ) diff --git a/test/utils/test_helpers.py b/test/utils/test_helpers.py index 4006c4ad50101e93b10ca16c802761612a244ea7..5dee0aa8f7a5cb981771a63cdfec933986423991 100644 --- a/test/utils/test_helpers.py +++ b/test/utils/test_helpers.py @@ -3,8 +3,8 @@ from unittest.mock import patch import pytest from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock -from gso.products.product_blocks.router import RouterVendor from gso.utils.helpers import available_interfaces_choices_including_current_members, validate_tt_number +from gso.utils.shared_enums import Vendor @pytest.fixture() @@ -34,20 +34,20 @@ def generate_tt_numbers(faker, request): def test_non_nokia_router_returns_none(mock_router, faker): - mock_router.from_subscription.return_value.router.vendor = RouterVendor.JUNIPER + mock_router.from_subscription.return_value.router.vendor = Vendor.JUNIPER result = available_interfaces_choices_including_current_members(faker.uuid4(), "10G", []) assert result is None def test_nokia_router_with_no_interfaces_returns_empty_choice(mock_router, mock_netbox_client, faker): - mock_router.from_subscription.return_value.router.vendor = RouterVendor.NOKIA + mock_router.from_subscription.return_value.router.vendor = Vendor.NOKIA mock_netbox_client().get_available_interfaces.return_value = iter([]) result = available_interfaces_choices_including_current_members(faker.uuid4(), "10G", []) assert len(result) == 0 def test_nokia_router_with_interfaces_returns_choice(mock_router, mock_netbox_client, faker): - mock_router.from_subscription.return_value.router.vendor = RouterVendor.NOKIA + mock_router.from_subscription.return_value.router.vendor = Vendor.NOKIA mock_netbox_client().get_available_interfaces.return_value = iter( [ {"name": "interface1", "module": {"display": "module1"}, "description": "desc1"}, diff --git a/test/workflows/iptrunk/test_activate_iptrunk.py b/test/workflows/iptrunk/test_activate_iptrunk.py new file mode 100644 index 0000000000000000000000000000000000000000..837f340c6adc77e1aa148760e425429288e4d01f --- /dev/null +++ b/test/workflows/iptrunk/test_activate_iptrunk.py @@ -0,0 +1,36 @@ +import pytest + +from gso.products import Iptrunk +from test.workflows import ( + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, +) + + +@pytest.mark.workflow() +def test_activate_router_success( + iptrunk_subscription_factory, + faker, +): + # Set up mock return values + product_id = iptrunk_subscription_factory(status="provisioning") + # Sanity check + assert Iptrunk.from_subscription(product_id).status == "provisioning" + + # Run workflow + initial_input_data = [{"subscription_id": product_id}, {}] + result, process_stat, step_log = run_workflow("activate_iptrunk", initial_input_data) + + assert_suspended(result) + result, step_log = resume_workflow(process_stat, step_log, input_data=[{"checklist_url": "http://localhost"}]) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Iptrunk.from_subscription(subscription_id) + + assert subscription.status == "active" diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index c069618476f87b4beafcf4fbcb89f259fc043071..b85f6d3fc586949dd07d53f5694e23d251eb69f5 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -5,9 +5,9 @@ import pytest from gso.products import Iptrunk, ProductType from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity -from gso.products.product_blocks.router import RouterVendor from gso.services.subscriptions import get_product_id_by_name from gso.utils.helpers import LAGMember +from gso.utils.shared_enums import Vendor from test.services.conftest import MockedNetboxClient from test.workflows import ( assert_complete, @@ -43,11 +43,11 @@ def _netbox_client_mock(): @pytest.fixture() def input_form_wizard_data(request, juniper_router_subscription_factory, nokia_router_subscription_factory, faker): - vendor = getattr(request, "param", RouterVendor.NOKIA) + vendor = getattr(request, "param", Vendor.NOKIA) router_side_a = nokia_router_subscription_factory() # Set side b router to Juniper - if vendor == RouterVendor.JUNIPER: + if vendor == Vendor.JUNIPER: router_side_b = juniper_router_subscription_factory() side_b_members = faker.link_members_juniper() else: @@ -165,7 +165,7 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( assert mock_execute_playbook.call_count == 2 -@pytest.mark.parametrize("input_form_wizard_data", [RouterVendor.JUNIPER], indirect=True) +@pytest.mark.parametrize("input_form_wizard_data", [Vendor.JUNIPER], indirect=True) @pytest.mark.workflow() @patch("gso.workflows.iptrunk.create_iptrunk.execute_playbook") @patch("gso.workflows.iptrunk.create_iptrunk.infoblox.allocate_v6_network") diff --git a/test/workflows/iptrunk/test_migrate_iptrunk.py b/test/workflows/iptrunk/test_migrate_iptrunk.py index c7ca62bf11a89dbde7aa9d3bf2297eef2c5651cd..825b9e7c779287e3b42ff11d01413562ff66d44b 100644 --- a/test/workflows/iptrunk/test_migrate_iptrunk.py +++ b/test/workflows/iptrunk/test_migrate_iptrunk.py @@ -4,8 +4,8 @@ from unittest.mock import patch import pytest from gso.products import Iptrunk -from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.router import Router +from gso.utils.shared_enums import Vendor from test import USER_CONFIRM_EMPTY_FORM from test.conftest import UseJuniperSide from test.workflows import ( @@ -189,15 +189,15 @@ def test_migrate_iptrunk_success( vendor_new = Router.from_subscription(new_router).router.vendor # Only Nokia will be checked on netbox - num_nokia_lags = 1 if vendor_new == RouterVendor.NOKIA else 0 - num_nokia_reserved = 2 * (vendor_new == RouterVendor.NOKIA) - num_nokia_attached = 2 * (vendor_new == RouterVendor.NOKIA) + num_nokia_lags = 1 if vendor_new == Vendor.NOKIA else 0 + num_nokia_reserved = 2 * (vendor_new == Vendor.NOKIA) + num_nokia_attached = 2 * (vendor_new == Vendor.NOKIA) # Only interfaces lag delete for nokia node is tested - num_nokia_lag_del = 1 * (vendor_old == RouterVendor.NOKIA) + num_nokia_lag_del = 1 * (vendor_old == Vendor.NOKIA) # Only free interfaces when node was nokia - num_nokia_free = 2 * (vendor_old == RouterVendor.NOKIA) + num_nokia_free = 2 * (vendor_old == Vendor.NOKIA) # Assert all Netbox calls have been made assert mocked_create_interface.call_count == num_nokia_lags # once for creating the LAG on the newly replaced side: diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py index b3b15a75e8904eab51b767fb6ef3d527169efff1..78bedca5562302e2b6e6c12e3e71bbbb93bf7758 100644 --- a/test/workflows/iptrunk/test_modify_trunk_interface.py +++ b/test/workflows/iptrunk/test_modify_trunk_interface.py @@ -4,7 +4,7 @@ import pytest from gso.products import Iptrunk from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity -from gso.products.product_blocks.router import RouterVendor +from gso.utils.shared_enums import Vendor from test.conftest import UseJuniperSide from test.workflows import ( assert_complete, @@ -141,15 +141,15 @@ def test_iptrunk_modify_trunk_interface_success( # Only Nokia interfaces are checked vendor_side_a = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.vendor vendor_side_b = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.vendor - num_ifaces = (len(new_side_a_ae_members) if vendor_side_a == RouterVendor.NOKIA else 0) + ( - len(new_side_b_ae_members) if vendor_side_b == RouterVendor.NOKIA else 0 + num_ifaces = (len(new_side_a_ae_members) if vendor_side_a == Vendor.NOKIA else 0) + ( + len(new_side_b_ae_members) if vendor_side_b == Vendor.NOKIA else 0 ) # Define free interfaces for only nokia sides - num_free_ifaces = 2 * (vendor_side_a == RouterVendor.NOKIA) + 2 * (vendor_side_b == RouterVendor.NOKIA) + num_free_ifaces = 2 * (vendor_side_a == Vendor.NOKIA) + 2 * (vendor_side_b == Vendor.NOKIA) # lag interface for nokia sides - num_lag_ifaces = int(vendor_side_a == RouterVendor.NOKIA) + int(vendor_side_b == RouterVendor.NOKIA) + num_lag_ifaces = int(vendor_side_a == Vendor.NOKIA) + int(vendor_side_b == Vendor.NOKIA) assert mocked_reserve_interface.call_count == num_ifaces # Only nokia interfaces per side num is randomly generated assert mocked_attach_interface_to_lag.call_count == num_ifaces diff --git a/test/workflows/router/test_activate_router.py b/test/workflows/router/test_activate_router.py new file mode 100644 index 0000000000000000000000000000000000000000..2e60fd7ce60e11bca0f4ae2e4c47d49e244a38e0 --- /dev/null +++ b/test/workflows/router/test_activate_router.py @@ -0,0 +1,36 @@ +import pytest + +from gso.products import Router +from test.workflows import ( + assert_complete, + assert_suspended, + extract_state, + resume_workflow, + run_workflow, +) + + +@pytest.mark.workflow() +def test_activate_router_success( + nokia_router_subscription_factory, + faker, +): + # Set up mock return values + product_id = nokia_router_subscription_factory(status="provisioning") + # Sanity check + assert Router.from_subscription(product_id).status == "provisioning" + + # Run workflow + initial_input_data = [{"subscription_id": product_id}, {}] + result, process_stat, step_log = run_workflow("activate_router", initial_input_data) + + assert_suspended(result) + result, step_log = resume_workflow(process_stat, step_log, input_data=[{"checklist_url": "http://localhost"}]) + + assert_complete(result) + + state = extract_state(result) + subscription_id = state["subscription_id"] + subscription = Router.from_subscription(subscription_id) + + assert subscription.status == "active" diff --git a/test/workflows/router/test_create_router.py b/test/workflows/router/test_create_router.py index ca012757153478de3ca4e50fa37b44daa9696595..efa7e6732c524cbf1d6edf0584fe207b070efcdf 100644 --- a/test/workflows/router/test_create_router.py +++ b/test/workflows/router/test_create_router.py @@ -4,9 +4,10 @@ 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_blocks.router import RouterRole from gso.products.product_types.router import Router from gso.services.subscriptions import get_product_id_by_name +from gso.utils.shared_enums import Vendor from test import USER_CONFIRM_EMPTY_FORM from test.workflows import ( assert_complete, @@ -29,7 +30,7 @@ def router_creation_input_form_data(site_subscription_factory, faker): "hostname": faker.pystr(), "ts_port": faker.pyint(), "router_role": faker.random_choices(elements=(RouterRole.P, RouterRole.PE, RouterRole.AMT), length=1)[0], - "vendor": RouterVendor.NOKIA, + "vendor": Vendor.NOKIA, }