diff --git a/MANIFEST.in b/MANIFEST.in index b0e9aee289f44131d6ac6fb0cf8fc2a28a28ddd9..c0097b2d5db1dab47f80d3902abdebd19cbddbf4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include gso/migrations/*.py include gso/migrations/*.mako include gso/migrations/versions/*.py include gso/translations/*.json +include gso/alembic.ini diff --git a/build-docs.sh b/build-docs.sh index e7c1a5fb1d9ac0fa5646cf6a4d4a345536947001..016b34edae07b74c348218478f5a149c22a50765 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -1,4 +1,4 @@ -pip install sphinx_rtd_theme myst-parser +pip install sphinx_rtd_theme sphinxcontrib-jquery cd docs || exit 1 sphinx-build source build diff --git a/docs/.gitlab-ci.yml b/docs/.gitlab-ci.yml index c9d2fff925f56888c96d6fbf324116bce305e285..68d7fd2a3ea36f9a3a4b331e014b6cecef156d36 100644 --- a/docs/.gitlab-ci.yml +++ b/docs/.gitlab-ci.yml @@ -7,7 +7,7 @@ build-documentation: image: sphinxdoc/sphinx:latest before_script: - - pip install sphinx_rtd_theme sphinx-autodoc2 myst-parser + - pip install sphinx_rtd_theme sphinxcontrib-jquery - cd $CI_PROJECT_DIR/docs/source script: - make html diff --git a/docs/source/apidocs/.gitignore b/docs/source/apidocs/.gitignore deleted file mode 100644 index 72e8ffc0db8aad71a934dd11e5968bd5109e54b4..0000000000000000000000000000000000000000 --- a/docs/source/apidocs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* diff --git a/docs/source/conf.py b/docs/source/conf.py index 2d75bf85feb81b24b2b5cffc40e4881eb9dfa56c..3f4d36bd86b823b9fe642011cd62c8efa55ee16a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,18 +4,10 @@ copyright = '2023, GÉANT Vereniging' author = 'GÉANT Orchestration and Automation Team' # -- General configuration --------------------------------------------------- -extensions = ['sphinx_rtd_theme', 'myst_parser', 'sphinx.ext.autodoc'] +extensions = ['sphinx_rtd_theme', 'sphinx.ext.autodoc', 'sphinxcontrib.jquery'] templates_path = ['templates'] exclude_patterns = ['build', 'Thumbs.db', '.DS_Store', 'venv', 'vale', '__init__.py'] -source_suffix = { - '.md': 'markdown' -} - -# -- Options for Markdown support -------------------------------------------- -myst_enable_extensions = ['attrs_block', 'deflist', 'replacements', 'smartquotes', 'strikethrough', 'fieldlist'] -suppress_warnings = ['myst.strikethrough'] - # -- Options for HTML output ------------------------------------------------- html_theme = 'sphinx_rtd_theme' @@ -24,11 +16,12 @@ html_theme_options = { 'style_nav_header_background': 'rgb(0 63 95)', } html_css_files = ['custom.css'] +html_js_files = ['custom.js'] html_logo = 'static/geant_logo_white.svg' -# Both the class' and the __init__ method's docstring are concatenated and inserted. +# Both the class' and the ``__init__`` method's docstring are concatenated and inserted. autoclass_content = 'both' -# autodoc_typehints = 'none' +autodoc_typehints = 'none' # Display todos by setting to True todo_include_todos = True diff --git a/docs/source/glossary.md b/docs/source/glossary.md deleted file mode 100644 index 7035fa3bbb5019fbde57c70deeb66694924ff233..0000000000000000000000000000000000000000 --- a/docs/source/glossary.md +++ /dev/null @@ -1,41 +0,0 @@ -# Glossary of terms - -{.glossary} -BGP -: Border Gateway Protocol: a path vector routing protocol described in -<a href="https://datatracker.ietf.org/doc/html/rfc4271" target="_blank">RFC 4271</a>. - -CNAME -: A type of DNS record that is used as an alias from one hostname to another - -CRUD -: Create, Read, Update, Delete - -FQDN -: Fully Quantified Domain Name - -GSO -: GÉANT Service Orchestrator - -IPAM -: IP Address Management - -IS-IS -: Intermediate System to Intermediate System: a routing protocol described in -<a href="https://datatracker.ietf.org/doc/html/rfc7142" target="_blank">RFC 7142</a>. - -ISO -: International Organisation for Standardisation - -LSO -: Lightweight Service Orchestrator - -NET -: Network Entity Title: used for {term}`IS-IS` routing. - -SNMP -: Simple Network Management Protocol: a protocol that's used for gathering data, widely used for network management and -monitoring. - -WFO -: <a href="https://workfloworchestrator.org/" target="_blank">Workflow Orchestrator</a> diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst new file mode 100644 index 0000000000000000000000000000000000000000..bb7a2a69ca2c76900be5d138f6f3db610528538b --- /dev/null +++ b/docs/source/glossary.rst @@ -0,0 +1,56 @@ +Glossary of terms +================= + +.. glossary:: + + BGP + Border Gateway Protocol: a path vector routing protocol described in + `RFC 4271 <https://datatracker.ietf.org/doc/html/rfc4271>`_. + + CIDR + Classless Inter-Domain Routing. A method for denoting IP ranges in the form of ``9.9.0.0/16`` or + ``fe80:1234:abcd::/48``. + + CNAME + A type of DNS record that is used as an alias from one hostname to another + + CRUD + Create, Read, Update, Delete + + FQDN + Fully Quantified Domain Name + + GSO + GÉANT Service Orchestrator + + IPAM + IP Address Management + + IS-IS + Intermediate System to Intermediate System: a routing protocol described in + `RFC 7142 <https://datatracker.ietf.org/doc/html/rfc7142>`_. + + ISO + International Organisation for Standardisation + + LAG + Link Aggregation: a bundle of multiple network connections. + + LSO + Lightweight Service Orchestrator + + NET + Network Entity Title: used for :term:`IS-IS` routing. + + OSS + Operational Support Systems + + SNMP + Simple Network Management Protocol: a protocol that's used for gathering data, widely used for network management + and monitoring. + + UUID + Universally Unique Identifier + + WFO + `Workflow Orchestrator <https://workfloworchestrator.org/>`_ diff --git a/docs/source/index.md b/docs/source/index.md deleted file mode 100644 index 8ed5ec92290d7b9b5c564cdb20cdd6ebcae9234b..0000000000000000000000000000000000000000 --- a/docs/source/index.md +++ /dev/null @@ -1,13 +0,0 @@ -# GÉANT Service Orchestrator ({term}`GSO`) - -Welcome to the documentation of the GÉANT Service Orchestrator, or {term}`GSO` for short. - -This documentation has the following sections: - -```{toctree} -:caption: Contents -:maxdepth: 1 -quickstart.md -apidocs/gso/gso -glossary.md -``` diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..5818da8afb4db3fb33ab27b8bf1da24d589b23d1 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,14 @@ +GÉANT Service Orchestrator +========================== + +Welcome to the documentation of the GÉANT Service Orchestrator, or :term:`GSO` for short. + +This documentation has the following sections: + +.. toctree:: + :caption: Contents + :maxdepth: 2 + + quickstart + modules + glossary diff --git a/docs/source/module/api/index.rst b/docs/source/module/api/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..c1e75155b0e7ffcfb829f6b42ef8e72eb62fed62 --- /dev/null +++ b/docs/source/module/api/index.rst @@ -0,0 +1,15 @@ +``gso.api`` +=========== + +.. automodule:: gso.api + :members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + v1/index diff --git a/docs/source/module/api/v1/imports.rst b/docs/source/module/api/v1/imports.rst new file mode 100644 index 0000000000000000000000000000000000000000..b40e8edaed706c8656407fa1fc8d1b3d9bc060be --- /dev/null +++ b/docs/source/module/api/v1/imports.rst @@ -0,0 +1,6 @@ +``gso.api.v1.imports`` +====================== + +.. automodule:: gso.api.v1.imports + :members: + :show-inheritance: diff --git a/docs/source/module/api/v1/index.rst b/docs/source/module/api/v1/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..c5a4a0c55a90ccc798d55190e87c52336c56f1c4 --- /dev/null +++ b/docs/source/module/api/v1/index.rst @@ -0,0 +1,15 @@ +``gso.api.v1`` +============== + +.. automodule:: gso.api.v1 + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + imports diff --git a/docs/source/module/cli/import_sites.rst b/docs/source/module/cli/import_sites.rst new file mode 100644 index 0000000000000000000000000000000000000000..f7e19edef1744d40c3fddacf60efb6f48417aa54 --- /dev/null +++ b/docs/source/module/cli/import_sites.rst @@ -0,0 +1,6 @@ +``gso.cli.import_sites`` +======================== + +.. automodule:: gso.cli.import_sites + :members: + :show-inheritance: diff --git a/docs/source/module/cli/index.rst b/docs/source/module/cli/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..02bb0773058c3fb779f121c483a87fec5e56b0fa --- /dev/null +++ b/docs/source/module/cli/index.rst @@ -0,0 +1,16 @@ +``gso.cli`` +=========== + +.. automodule:: gso.cli + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + import_sites + netbox diff --git a/docs/source/module/cli/netbox.rst b/docs/source/module/cli/netbox.rst new file mode 100644 index 0000000000000000000000000000000000000000..8d379335ee1211f930d76d79a576bafe16a2fe68 --- /dev/null +++ b/docs/source/module/cli/netbox.rst @@ -0,0 +1,6 @@ +``gso.cli.netbox`` +================== + +.. automodule:: gso.cli.netbox + :members: + :show-inheritance: diff --git a/docs/source/module/products/index.rst b/docs/source/module/products/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..c68072cc071e570f268408b67e656b63a1ed4bf6 --- /dev/null +++ b/docs/source/module/products/index.rst @@ -0,0 +1,21 @@ +``gso.products`` +================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 1 + + product_blocks/index + product_types/index + +Submodules +---------- + +``gso.products.shared`` module +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: gso.products.shared + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_blocks/index.rst b/docs/source/module/products/product_blocks/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..309e18b62f81f25b4cfffe6c60fd77b46bd07d06 --- /dev/null +++ b/docs/source/module/products/product_blocks/index.rst @@ -0,0 +1,19 @@ +``gso.products.product_blocks`` +=============================== + +``gso.products.product_blocks`` package +--------------------------------------- + +.. automodule:: gso.products.product_blocks + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 1 + + iptrunk + router + site diff --git a/docs/source/module/products/product_blocks/iptrunk.rst b/docs/source/module/products/product_blocks/iptrunk.rst new file mode 100644 index 0000000000000000000000000000000000000000..150b7cbad3da767e202598bb803a6680651494dc --- /dev/null +++ b/docs/source/module/products/product_blocks/iptrunk.rst @@ -0,0 +1,6 @@ +``gso.products.product_blocks.iptrunk`` +======================================= + +.. automodule:: gso.products.product_blocks.iptrunk + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_blocks/router.rst b/docs/source/module/products/product_blocks/router.rst new file mode 100644 index 0000000000000000000000000000000000000000..4ef96d9cbf0e5cf64345113f1fd91da8a063b1e3 --- /dev/null +++ b/docs/source/module/products/product_blocks/router.rst @@ -0,0 +1,6 @@ +``gso.products.product_blocks.router`` +====================================== + +.. automodule:: gso.products.product_blocks.router + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_blocks/site.rst b/docs/source/module/products/product_blocks/site.rst new file mode 100644 index 0000000000000000000000000000000000000000..4b1415f44545d3944eae88c29cb3390e5a5b4007 --- /dev/null +++ b/docs/source/module/products/product_blocks/site.rst @@ -0,0 +1,6 @@ +``gso.products.product_blocks.site`` +==================================== + +.. automodule:: gso.products.product_blocks.site + :members: + :show-inheritance: diff --git a/docs/source/module/products/product_types/index.rst b/docs/source/module/products/product_types/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..80b73721a04437a9b4e54060927bd6de6ef4dc31 --- /dev/null +++ b/docs/source/module/products/product_types/index.rst @@ -0,0 +1,9 @@ +``gso.products.product_types`` +============================== + +``gso.products.product_types`` package +-------------------------------------- + +.. automodule:: gso.products.product_types + :members: + :show-inheritance: diff --git a/docs/source/module/schemas/enums.rst b/docs/source/module/schemas/enums.rst new file mode 100644 index 0000000000000000000000000000000000000000..54dc21f967c8a02160ff4c1413d13719b5ad8be9 --- /dev/null +++ b/docs/source/module/schemas/enums.rst @@ -0,0 +1,6 @@ +``gso.schemas.enums`` +===================== + +.. automodule:: gso.schemas.enums + :members: + :show-inheritance: diff --git a/docs/source/module/schemas/imports.rst b/docs/source/module/schemas/imports.rst new file mode 100644 index 0000000000000000000000000000000000000000..2015ea3efeb9a85beb62b83753455bcdabcc39aa --- /dev/null +++ b/docs/source/module/schemas/imports.rst @@ -0,0 +1,6 @@ +``gso.schemas.imports`` +======================= + +.. automodule:: gso.schemas.imports + :members: + :show-inheritance: diff --git a/docs/source/module/schemas/index.rst b/docs/source/module/schemas/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..a56c90903015287946b1d7d9da853537c6a8523f --- /dev/null +++ b/docs/source/module/schemas/index.rst @@ -0,0 +1,17 @@ +``gso.schemas`` +=============== + +.. automodule:: gso.schemas + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + enums + imports + types diff --git a/docs/source/module/schemas/types.rst b/docs/source/module/schemas/types.rst new file mode 100644 index 0000000000000000000000000000000000000000..58b064999b4d89c88794dede5ba7eb1f065e1f1d --- /dev/null +++ b/docs/source/module/schemas/types.rst @@ -0,0 +1,6 @@ +``gso.schemas.types`` +===================== + +.. automodule:: gso.schemas.types + :members: + :show-inheritance: diff --git a/docs/source/module/services/crm.rst b/docs/source/module/services/crm.rst new file mode 100644 index 0000000000000000000000000000000000000000..cee4e5018343626463809b6a9cb7a4bb499937ff --- /dev/null +++ b/docs/source/module/services/crm.rst @@ -0,0 +1,6 @@ +``gso.services.crm`` +==================== + +.. automodule:: gso.services.crm + :members: + :show-inheritance: diff --git a/docs/source/module/services/index.rst b/docs/source/module/services/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..26190e2968a848d3fb06e491b52572dd4fca8faa --- /dev/null +++ b/docs/source/module/services/index.rst @@ -0,0 +1,19 @@ +``gso.services`` +================ + +.. automodule:: gso.services + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + crm + infoblox + netbox_client + provisioning_proxy + subscriptions diff --git a/docs/source/module/services/infoblox.rst b/docs/source/module/services/infoblox.rst new file mode 100644 index 0000000000000000000000000000000000000000..d05316c9a246659376d301cbfb657ce3ac558aaa --- /dev/null +++ b/docs/source/module/services/infoblox.rst @@ -0,0 +1,6 @@ +``gso.services.infoblox`` +========================= + +.. automodule:: gso.services.infoblox + :members: + :show-inheritance: diff --git a/docs/source/module/services/netbox_client.rst b/docs/source/module/services/netbox_client.rst new file mode 100644 index 0000000000000000000000000000000000000000..26891ede7814fe84c2f0c39031367cf9d7679e45 --- /dev/null +++ b/docs/source/module/services/netbox_client.rst @@ -0,0 +1,6 @@ +``gso.services.netbox_client`` +============================== + +.. automodule:: gso.services.netbox_client + :members: + :show-inheritance: diff --git a/docs/source/module/services/provisioning_proxy.rst b/docs/source/module/services/provisioning_proxy.rst new file mode 100644 index 0000000000000000000000000000000000000000..756edf2642d865e3104af928fbddd047c3d4ed32 --- /dev/null +++ b/docs/source/module/services/provisioning_proxy.rst @@ -0,0 +1,6 @@ +``gso.services.provisioning_proxy`` +=================================== + +.. automodule:: gso.services.provisioning_proxy + :members: + :show-inheritance: diff --git a/docs/source/module/services/subscriptions.rst b/docs/source/module/services/subscriptions.rst new file mode 100644 index 0000000000000000000000000000000000000000..8b2bfbed50c0f873814049cfd3a6aed3409500a9 --- /dev/null +++ b/docs/source/module/services/subscriptions.rst @@ -0,0 +1,6 @@ +``gso.services.subscriptions`` +============================== + +.. automodule:: gso.services.subscriptions + :members: + :show-inheritance: diff --git a/docs/source/module/utils/device_info.rst b/docs/source/module/utils/device_info.rst new file mode 100644 index 0000000000000000000000000000000000000000..4a332bd7d9caab2d64555fed4f9efab4ddb8d272 --- /dev/null +++ b/docs/source/module/utils/device_info.rst @@ -0,0 +1,6 @@ +``gso.utils.device_info`` +========================= + +.. automodule:: gso.utils.device_info + :members: + :show-inheritance: diff --git a/docs/source/module/utils/exceptions.rst b/docs/source/module/utils/exceptions.rst new file mode 100644 index 0000000000000000000000000000000000000000..98414a525231a1488358aeaeed8cb4aea4025729 --- /dev/null +++ b/docs/source/module/utils/exceptions.rst @@ -0,0 +1,6 @@ +``gso.utils.exceptions`` +======================== + +.. automodule:: gso.utils.exceptions + :members: + :show-inheritance: diff --git a/docs/source/module/utils/index.rst b/docs/source/module/utils/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..1b0b55295fbef85d1eb3fb539efc7a48b96dbd9c --- /dev/null +++ b/docs/source/module/utils/index.rst @@ -0,0 +1,16 @@ +``gso.utils`` +============= + +.. automodule:: gso.utils + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + device_info + exceptions diff --git a/docs/source/module/workflows/index.rst b/docs/source/module/workflows/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..b89cf2bce0132dda7478def4ff5a55f6b18a4096 --- /dev/null +++ b/docs/source/module/workflows/index.rst @@ -0,0 +1,27 @@ +``gso.workflows`` +================= + +.. automodule:: gso.workflows + :members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + iptrunk/index + router/index + site/index + tasks/index + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + utils diff --git a/docs/source/module/workflows/iptrunk/create_iptrunk.rst b/docs/source/module/workflows/iptrunk/create_iptrunk.rst new file mode 100644 index 0000000000000000000000000000000000000000..3105f051175521d0c7dd7f40fdc51902b83ccc89 --- /dev/null +++ b/docs/source/module/workflows/iptrunk/create_iptrunk.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.create_iptrunk`` +======================================== + +.. automodule:: gso.workflows.iptrunk.create_iptrunk + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/index.rst b/docs/source/module/workflows/iptrunk/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..3bfaec18bfb011680bf0ef5b902b587aeb6b3e6c --- /dev/null +++ b/docs/source/module/workflows/iptrunk/index.rst @@ -0,0 +1,20 @@ +``gso.workflows.iptrunk`` +========================= + +.. automodule:: gso.workflows.iptrunk + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + create_iptrunk + migrate_iptrunk + modify_isis_metric + modify_trunk_interface + terminate_iptrunk + utils diff --git a/docs/source/module/workflows/iptrunk/migrate_iptrunk.rst b/docs/source/module/workflows/iptrunk/migrate_iptrunk.rst new file mode 100644 index 0000000000000000000000000000000000000000..3ae52e5f71428e25c1281f8e35b8d2f4eb0adc90 --- /dev/null +++ b/docs/source/module/workflows/iptrunk/migrate_iptrunk.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.migrate_iptrunk`` +========================================= + +.. automodule:: gso.workflows.iptrunk.migrate_iptrunk + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/modify_isis_metric.rst b/docs/source/module/workflows/iptrunk/modify_isis_metric.rst new file mode 100644 index 0000000000000000000000000000000000000000..3f3a17deb07f87911a507f256e25bfcbba46cadf --- /dev/null +++ b/docs/source/module/workflows/iptrunk/modify_isis_metric.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.modify_isis_metric`` +============================================ + +.. automodule:: gso.workflows.iptrunk.modify_isis_metric + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/modify_trunk_interface.rst b/docs/source/module/workflows/iptrunk/modify_trunk_interface.rst new file mode 100644 index 0000000000000000000000000000000000000000..aad531eadc83b6c78c0e744931c41fa0502aa138 --- /dev/null +++ b/docs/source/module/workflows/iptrunk/modify_trunk_interface.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.modify_trunk_interface`` +================================================ + +.. automodule:: gso.workflows.iptrunk.modify_trunk_interface + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/terminate_iptrunk.rst b/docs/source/module/workflows/iptrunk/terminate_iptrunk.rst new file mode 100644 index 0000000000000000000000000000000000000000..9a11c0c4bb82ddadd68f5e96ba8821fd9c91f91b --- /dev/null +++ b/docs/source/module/workflows/iptrunk/terminate_iptrunk.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.terminate_iptrunk`` +=========================================== + +.. automodule:: gso.workflows.iptrunk.terminate_iptrunk + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/iptrunk/utils.rst b/docs/source/module/workflows/iptrunk/utils.rst new file mode 100644 index 0000000000000000000000000000000000000000..96f665efadcf6e1286eef83b061ee27e574898e4 --- /dev/null +++ b/docs/source/module/workflows/iptrunk/utils.rst @@ -0,0 +1,6 @@ +``gso.workflows.iptrunk.utils`` +=============================== + +.. automodule:: gso.workflows.iptrunk.utils + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/router/create_router.rst b/docs/source/module/workflows/router/create_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..e30abc03c292c14d9420bc7d0eef0da07e68eca6 --- /dev/null +++ b/docs/source/module/workflows/router/create_router.rst @@ -0,0 +1,6 @@ +``gso.workflows.router.create_router`` +====================================== + +.. automodule:: gso.workflows.router.create_router + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/router/index.rst b/docs/source/module/workflows/router/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..11c3f71c734ac62f2e3ea194222eb6478816b335 --- /dev/null +++ b/docs/source/module/workflows/router/index.rst @@ -0,0 +1,16 @@ +``gso.workflows.router`` +======================== + +.. automodule:: gso.workflows.router + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + create_router + terminate_router diff --git a/docs/source/module/workflows/router/terminate_router.rst b/docs/source/module/workflows/router/terminate_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..0ac1ec450f4c88d79150227f4d7f4f08c25404ac --- /dev/null +++ b/docs/source/module/workflows/router/terminate_router.rst @@ -0,0 +1,6 @@ +``gso.workflows.router.terminate_router`` +========================================= + +.. automodule:: gso.workflows.router.terminate_router + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/site/create_site.rst b/docs/source/module/workflows/site/create_site.rst new file mode 100644 index 0000000000000000000000000000000000000000..45a9ef49d0d8bbc38a8ead230288330dc67e081f --- /dev/null +++ b/docs/source/module/workflows/site/create_site.rst @@ -0,0 +1,6 @@ +``gso.workflows.site.create_site`` +================================== + +.. automodule:: gso.workflows.site.create_site + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/site/index.rst b/docs/source/module/workflows/site/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..396ddca109acad7044fb5e5fb05bc7d02608de5c --- /dev/null +++ b/docs/source/module/workflows/site/index.rst @@ -0,0 +1,15 @@ +``gso.workflows.site`` +====================== + +.. automodule:: gso.workflows.site + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + create_site diff --git a/docs/source/module/workflows/tasks/import_iptrunk.rst b/docs/source/module/workflows/tasks/import_iptrunk.rst new file mode 100644 index 0000000000000000000000000000000000000000..24a4be08f99a22f03e107ebef8d1cb58e5d0815b --- /dev/null +++ b/docs/source/module/workflows/tasks/import_iptrunk.rst @@ -0,0 +1,6 @@ +``gso.workflows.tasks.import_iptrunk`` +====================================== + +.. automodule:: gso.workflows.tasks.import_iptrunk + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/tasks/import_router.rst b/docs/source/module/workflows/tasks/import_router.rst new file mode 100644 index 0000000000000000000000000000000000000000..65a6e60741a9d13b4dd3fc2dc3d796550b12b22e --- /dev/null +++ b/docs/source/module/workflows/tasks/import_router.rst @@ -0,0 +1,6 @@ +``gso.workflows.tasks.import_router`` +===================================== + +.. automodule:: gso.workflows.tasks.import_router + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/tasks/import_site.rst b/docs/source/module/workflows/tasks/import_site.rst new file mode 100644 index 0000000000000000000000000000000000000000..eb9280dff87c304ac2d7853931ce64203929851e --- /dev/null +++ b/docs/source/module/workflows/tasks/import_site.rst @@ -0,0 +1,6 @@ +``gso.workflows.tasks.import_site`` +=================================== + +.. automodule:: gso.workflows.tasks.import_site + :members: + :show-inheritance: diff --git a/docs/source/module/workflows/tasks/index.rst b/docs/source/module/workflows/tasks/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..8feb3de360ac547ed291e2dfceb07d84e5724c75 --- /dev/null +++ b/docs/source/module/workflows/tasks/index.rst @@ -0,0 +1,17 @@ +``gso.workflows.tasks`` +======================= + +.. automodule:: gso.workflows.tasks + :members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + import_iptrunk + import_router + import_site diff --git a/docs/source/module/workflows/utils.rst b/docs/source/module/workflows/utils.rst new file mode 100644 index 0000000000000000000000000000000000000000..85c007f728710bf73912dc96609e6d63084a945a --- /dev/null +++ b/docs/source/module/workflows/utils.rst @@ -0,0 +1,6 @@ +``gso.workflows.utils`` +======================= + +.. automodule:: gso.workflows.utils + :members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000000000000000000000000000000000000..37bab35941863cced5a394247e5da7262b6f9f92 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,31 @@ +========================= +Sub-packages and -modules +========================= + +This page lists references to the documentation of all sub-packages and -modules that make up :term:`GSO`. + +Subpackages +----------- + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + module/api/index + module/cli/index + module/products/index + module/schemas/index + module/services/index + module/utils/index + module/workflows/index + +Submodules +---------- + +``gso.settings`` module +^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: gso.settings + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md deleted file mode 100644 index 75921374869c27bd07ec2363ac4609eddf64bba7..0000000000000000000000000000000000000000 --- a/docs/source/quickstart.md +++ /dev/null @@ -1,62 +0,0 @@ -# Quickstart - -## Development environment and dependencies - -- Install python 3.10 if you don't have it already: - - ``add-apt-repository ppa:deadsnakes/ppa`` - - ``apt install python3.10 python3.10-distutils`` -- Follow Steps 1 and 2 from here to install dependencies and setup DB: https://workfloworchestrator.org/orchestrator-core/workshops/beginner/debian/ -- To install the orchestrator GUI, you can follow the steps 5 and 6 from the previous link. -- Create a virtual environment: - - ``source /usr/share/virtualenvwrapper/virtualenvwrapper.sh`` - - ``mkvirtualenv --python python3.10 gso`` -- To use the virtual environment: - - `source /usr/share/virtualenvwrapper/virtualenvwrapper.sh` - - `workon gso` - -## Installation - -Do all this inside the virtual environment. -- Clone this repository and ``cd`` into it -- ``pip install -r requirements.txt`` - - If you get an error because you pip version is too old, run this: `curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10` -- ``pip install -e .`` -- `cd` into `gso` directory and create ``oss-params.json`` based on the ``oss-params-example.json`` file. -- Export the oss-params file: ``export OSS_PARAMS_FILENAME="/path/to/oss-params.json"`` -- Upgrade DB to latest revision: `PYTHONPATH=. python main.py db upgrade heads` - -## Run - -- Start the workflow orchestrator core with ``uvicorn --host 0.0.0.0 --port 8080 main:app`` from inside the ``gso`` directory of this repository. -- Start the GUI with ``yarn start``. - - -## Useful workflow orchestrator resources - -- WFO API (Swagger): <https://workfloworchestrator.org/orchestrator-core/architecture/application/api/> -- WFO API (repository): <https://github.com/workfloworchestrator/orchestrator-core/tree/main/orchestrator/api/api_v1/endpoints> -- Carolina's notes: <https://wiki.geant.org/pages/viewpage.action?pageId=562921625> - -### API examples - -*Note: update IP address in the request to your own where you're running WFO.* - -**Creating subscriptions** (look at the `initial_input_form_generator` method of the workflow to find what properties need to be in the body of the POST request) - -Create process that produces a new subscription (CREATE workflow) - -``curl -X POST -H "Content-Type: application/json" http://10.98.1.62:8080/api/processes/create_trunk --data '[{"product": "321045fc-21ec-476b-b67b-5f211e47c3d7"},{"trunk_name": "mytrunkfromapi", "geant_s_sid": "a1b2c3"}]'`` - -Create process that manipulates the state of a subscription (TERMINATE workflow) - -``curl -X POST -H "Content-Type: application/json" http://10.98.1.62:8080/api/processes/terminate_trunk --data '[{"subscription_id": "910e7044-c427-46d1-afc3-7817e221d45d"}, {"are_you_sure": "yes"}]'`` - -**Deleting subscriptions** (not the same as running a TERMINATE workflow for a subscription, but to actually erase it from the WFO database) - -To do this, you first have to delete the processes first: - -``curl -X 'DELETE' 'http://10.98.1.62:8080/api/processes/7447a3e9-7c58-4ae7-8804-c0a75c39c924'`` - -Delete a subscription: - -``curl -X 'DELETE' 'http://10.98.1.62:8080/api/subscriptions/3f16532b-0a48-4fe3-802a-36b3f54eed0e'`` diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst new file mode 100644 index 0000000000000000000000000000000000000000..bc9d7e0fb5d15dcabc903fde0f2b05ed99601f04 --- /dev/null +++ b/docs/source/quickstart.rst @@ -0,0 +1,47 @@ +Quickstart +========== + +Development environment and dependencies +---------------------------------------- + +- Install python 3.10 if you do not have it already: + - ``add-apt-repository ppa:deadsnakes/ppa`` + - ``apt install python3.10 python3.10-distutils`` +- Follow Steps 1 and 2 from here to install dependencies and setup DB: + `<https://workfloworchestrator.org/orchestrator-core/workshops/beginner/debian/>`_ +- To install the orchestrator GUI, you can follow the steps 5 and 6 from the previous link. +- Create a virtual environment: + - ``source /usr/share/virtualenvwrapper/virtualenvwrapper.sh`` + - ``mkvirtualenv --python python3.10 gso`` +- To use the virtual environment: + - ``source /usr/share/virtualenvwrapper/virtualenvwrapper.sh`` + - ``workon gso`` + +Installation +------------ + +Do all this inside the virtual environment. + +- Clone this repository +- ``pip install -r requirements.txt`` + - If you get an error because you pip version is too old, run this: + ``curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10`` +- ``pip install -e .`` +- Create an ``oss-params.json`` based on the ``oss-params-example.json`` file inside ``/gso``. +- Export the oss-params file: ``export OSS_PARAMS_FILENAME="/path/to/oss-params.json"`` +- Upgrade DB to latest revision: ``PYTHONPATH=. python main.py db upgrade heads`` + +Run +--- + +- Start the workflow orchestrator core with ``uvicorn --host 0.0.0.0 --port 8080 main:app`` from inside the ``gso`` + directory of this repository. +- Start the GUI with ``yarn start``. + + +Useful workflow orchestrator resources +-------------------------------------- + +- WFO API (Swagger): `<https://workfloworchestrator.org/orchestrator-core/architecture/application/api/>`_ +- WFO API (repository): + `<https://github.com/workfloworchestrator/orchestrator-core/tree/main/orchestrator/api/api_v1/endpoints>`_ diff --git a/docs/source/static/custom.js b/docs/source/static/custom.js new file mode 100644 index 0000000000000000000000000000000000000000..184b5ae8b5a7a11a2e9c9d2407db9186d055e4a7 --- /dev/null +++ b/docs/source/static/custom.js @@ -0,0 +1,3 @@ +$(document).ready(function () { + $('a.external').attr('target', '_blank'); +}); diff --git a/docs/vale/.vale.ini b/docs/vale/.vale.ini index cc1a032a86a01aac5d50a594a348c7fa8a3979f6..76b4b40c4795fa870bdefc65e758f0476f747d5a 100644 --- a/docs/vale/.vale.ini +++ b/docs/vale/.vale.ini @@ -6,8 +6,8 @@ Vocab = geant-jargon, Sphinx Packages = proselint, Microsoft -[*.{md,py}] -; We only lint .md and .py files +[*.{py,rst}] +; We only lint .rst and .py files BasedOnStyles = Vale, proselint, Microsoft ; Some headers are generated and we have no real influence over them Microsoft.Headings = NO @@ -22,10 +22,13 @@ proselint.Typography = warning ; Same applies for not using contractions Microsoft.Contractions = NO custom.Contractions = YES +; Using a "regular" - instead of an en dash is totally fine +Microsoft.Negative = NO +Microsoft.RangeFormat = NO -TokenIgnores = ({term}), (:param \S+:), (:type \S+:) +TokenIgnores = (:term:`\S+`), (:param \S+(?: \S+)?:), (:type \S+:), (:return \S+:), (:rtype: \S+), (:class:`\S+`) -[*/glossary.md] +[*/glossary.rst] ; Ignore acronyms being undefined in the file that defines all acronyms by definition. Microsoft.Acronyms = NO Microsoft.Contractions = NO @@ -33,4 +36,4 @@ custom.Contractions = YES Microsoft.Passive = NO [formats] -py = md +py = rst diff --git a/gso/api/v1/imports.py b/gso/api/v1/imports.py index ae2b7c7df25af8ea8a27334ce02346ca63383f5d..bd8c0d5f2cb41b654e198d84f093d8d204714215 100644 --- a/gso/api/v1/imports.py +++ b/gso/api/v1/imports.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any from uuid import UUID from fastapi import Depends, HTTPException, status @@ -30,20 +30,16 @@ def _start_process(process_name: str, data: dict) -> UUID: @router.post("/sites", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel) -def import_site(site: SiteImportModel) -> Dict[str, Any]: +def import_site(site: SiteImportModel) -> dict[str, Any]: """Import a site by running the import_site workflow. - Args: - ---- - site (SiteImportModel): The site information to be imported. + :param site: The site information to be imported. + :type site: SiteImportModel - Returns: - ------- - dict: A dictionary containing the process id of the started process and detail message. + :return: A dictionary containing the process id of the started process and detail message. + :rtype: dict[str, Any] - Raises: - ------ - HTTPException: If the site already exists or if there's an error in the process. + :raises HTTPException: If the site already exists or if there's an error in the process. """ try: subscription = subscriptions.retrieve_subscription_by_subscription_instance_value( @@ -59,20 +55,16 @@ def import_site(site: SiteImportModel) -> Dict[str, Any]: @router.post("/routers", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel) -def import_router(router_data: RouterImportModel) -> Dict[str, Any]: +def import_router(router_data: RouterImportModel) -> dict[str, Any]: """Import a router by running the import_router workflow. - Args: - ---- - router_data (RouterImportModel): The router information to be imported. + :param router_data: The router information to be imported. + :type router_data: RouterImportModel - Returns: - ------- - dict: A dictionary containing the process id of the started process and detail message. + :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. + :raises HTTPException: If there's an error in the process. """ pid = _start_process("import_router", router_data.dict()) @@ -80,20 +72,16 @@ def import_router(router_data: RouterImportModel) -> Dict[str, Any]: @router.post("/iptrunks", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel) -def import_iptrunk(iptrunk_data: IptrunkImportModel) -> Dict[str, Any]: +def import_iptrunk(iptrunk_data: IptrunkImportModel) -> dict[str, Any]: """Import an iptrunk by running the import_iptrunk workflow. - Args: - ---- - iptrunk_data (IptrunkImportModel): The iptrunk information to be imported. + :param iptrunk_data: The iptrunk information to be imported. + :type iptrunk_data: IptrunkImportModel - Returns: - ------- - dict: A dictionary containing the process id of the started process and detail message. + :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. + :raises HTTPException: If there's an error in the process. """ pid = _start_process("import_iptrunk", iptrunk_data.dict()) diff --git a/gso/cli/netbox.py b/gso/cli/netbox.py index 97acdcf84bf0bd70320594c022087a6400f07486..97ee7bc16fedd17f976853faeb88dbd9bb3406f8 100644 --- a/gso/cli/netbox.py +++ b/gso/cli/netbox.py @@ -1,7 +1,8 @@ import typer from pynetbox import RequestError -from gso.services.netbox_client import NetBoxClient +from gso.services.netbox_client import NetboxClient +from gso.utils.device_info import DEFAULT_SITE, ROUTER_ROLE app: typer.Typer = typer.Typer() @@ -11,24 +12,24 @@ def netbox_initial_setup() -> None: """Set up NetBox for the first time. It includes: - - Creating a default site (GEANT) + - Creating a default site (GÉANT) - Creating device roles (Router) """ typer.echo("Initial setup of NetBox ...") typer.echo("Connecting to NetBox ...") - nbclient = NetBoxClient() + nbclient = NetboxClient() - typer.echo("Creating GEANT site ...") + typer.echo("Creating GÉANT site ...") try: - nbclient.create_device_site("GEANT", "geant") + nbclient.create_device_site(DEFAULT_SITE["name"], DEFAULT_SITE["slug"]) typer.echo("Site created successfully.") except RequestError as e: typer.echo(f"Error creating site: {e}") typer.echo("Creating Router device role ...") try: - nbclient.create_device_role("router", "router") + nbclient.create_device_role(ROUTER_ROLE["name"], ROUTER_ROLE["slug"]) typer.echo("Device role created successfully.") except RequestError as e: typer.echo(f"Error creating device role: {e}") diff --git a/gso/main.py b/gso/main.py index e05aac889c02934fb9842afaaeae39c89bb0dce0..112bd535c9df2054cae59225d3c3d16f9c38e242 100644 --- a/gso/main.py +++ b/gso/main.py @@ -1,4 +1,4 @@ -"""The main module that runs {term}`GSO`.""" +"""The main module that runs :term:`GSO`.""" import typer from orchestrator import OrchestratorCore from orchestrator.cli.main import app as core_cli diff --git a/gso/products/__init__.py b/gso/products/__init__.py index 16da3f77b14b198d5e980704057f705edfd9c632..df2529a3ff74a248f29490cd6fa49bbdd318fb65 100644 --- a/gso/products/__init__.py +++ b/gso/products/__init__.py @@ -1,4 +1,4 @@ -"""Module that updates the domain model of {term}`GSO`. Should contain all types of subscriptions.""" +"""Module that updates the domain model of :term:`GSO`. Should contain all types of subscriptions.""" from orchestrator.domain import SUBSCRIPTION_MODEL_REGISTRY from gso.products.product_types.iptrunk import Iptrunk diff --git a/gso/products/product_blocks/iptrunk.py b/gso/products/product_blocks/iptrunk.py index 89f54e7211a0974e97b18ee63e51c42b2ea7c7df..3eb992d189955e52ea7ee3fce27c55658fd98f1c 100644 --- a/gso/products/product_blocks/iptrunk.py +++ b/gso/products/product_blocks/iptrunk.py @@ -1,7 +1,7 @@ """IP trunk product block that has all parameters of a subscription throughout its lifecycle.""" import ipaddress -from typing import Optional, TypeVar +from typing import TypeVar from orchestrator.domain.base import ProductBlockModel from orchestrator.forms.validators import UniqueConstrainedList @@ -28,24 +28,24 @@ class IptrunkSideBlockInactive( ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="IptrunkSideBlock" ): iptrunk_side_node: RouterBlockInactive - iptrunk_side_ae_iface: Optional[str] = None - iptrunk_side_ae_geant_a_sid: Optional[str] = None + iptrunk_side_ae_iface: str | None = None + iptrunk_side_ae_geant_a_sid: str | None = None iptrunk_side_ae_members: list[str] = Field(default_factory=list) iptrunk_side_ae_members_description: list[str] = Field(default_factory=list) class IptrunkSideBlockProvisioning(IptrunkSideBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): iptrunk_side_node: RouterBlockProvisioning - iptrunk_side_ae_iface: Optional[str] = None - iptrunk_side_ae_geant_a_sid: Optional[str] = None + iptrunk_side_ae_iface: str | None = None + iptrunk_side_ae_geant_a_sid: str | None = None iptrunk_side_ae_members: list[str] = Field(default_factory=list) iptrunk_side_ae_members_description: list[str] = Field(default_factory=list) class IptrunkSideBlock(IptrunkSideBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): iptrunk_side_node: RouterBlock - iptrunk_side_ae_iface: Optional[str] = None - iptrunk_side_ae_geant_a_sid: Optional[str] = None + iptrunk_side_ae_iface: str | None = None + iptrunk_side_ae_geant_a_sid: str | None = None iptrunk_side_ae_members: list[str] = Field(default_factory=list) iptrunk_side_ae_members_description: list[str] = Field(default_factory=list) @@ -53,31 +53,31 @@ class IptrunkSideBlock(IptrunkSideBlockProvisioning, lifecycle=[SubscriptionLife class IptrunkBlockInactive( ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="IptrunkBlock" ): - """A trunk that's currently inactive, see {class}`IptrunkBlock`.""" - - geant_s_sid: Optional[str] = None - iptrunk_description: Optional[str] = None - iptrunk_type: Optional[IptrunkType] = None - iptrunk_speed: Optional[str] = None - iptrunk_minimum_links: Optional[int] = None - iptrunk_isis_metric: Optional[int] = None - iptrunk_ipv4_network: Optional[ipaddress.IPv4Network] = None - iptrunk_ipv6_network: Optional[ipaddress.IPv6Network] = None + """A trunk that's currently inactive, see :class:`IptrunkBlock`.""" + + geant_s_sid: str | None = None + iptrunk_description: str | None = None + iptrunk_type: IptrunkType | None = None + iptrunk_speed: str | None = None + iptrunk_minimum_links: int | None = None + iptrunk_isis_metric: int | None = None + iptrunk_ipv4_network: ipaddress.IPv4Network | None = None + iptrunk_ipv6_network: ipaddress.IPv6Network | None = None # iptrunk_sides: IptrunkSides[IptrunkSideBlockInactive] class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): - """A trunk that's currently being provisioned, see {class}`IptrunkBlock`.""" - - geant_s_sid: Optional[str] = None - iptrunk_description: Optional[str] = None - iptrunk_type: Optional[IptrunkType] = None - iptrunk_speed: Optional[str] = None - iptrunk_minimum_links: Optional[int] = None - iptrunk_isis_metric: Optional[int] = None - iptrunk_ipv4_network: Optional[ipaddress.IPv4Network] = None - iptrunk_ipv6_network: Optional[ipaddress.IPv6Network] = None + """A trunk that's currently being provisioned, see :class:`IptrunkBlock`.""" + + geant_s_sid: str | None = None + iptrunk_description: str | None = None + iptrunk_type: IptrunkType | None = None + iptrunk_speed: str | None = None + iptrunk_minimum_links: int | None = None + iptrunk_isis_metric: int | None = None + iptrunk_ipv4_network: ipaddress.IPv4Network | None = None + iptrunk_ipv6_network: ipaddress.IPv6Network | None = None # iptrunk_sides: IptrunkSides[IptrunkSideBlockProvisioning] @@ -85,20 +85,21 @@ class IptrunkBlockProvisioning(IptrunkBlockInactive, lifecycle=[SubscriptionLife class IptrunkBlock(IptrunkBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): """A trunk that's currently deployed in the network.""" + #: GÉANT service ID associated with this trunk. geant_s_sid: str - """GÉANT service ID associated with this trunk. """ + #: A human-readable description of this trunk. iptrunk_description: str - """A human-readable description of this trunk.""" + #: The type of trunk, can be either dark fibre or leased capacity. iptrunk_type: IptrunkType - """The type of trunk, can be either dark fibre or leased capacity.""" + #: The speed of the trunk, measured per interface associated with it. iptrunk_speed: str # FIXME: should be of PhyPortCapacity type - """The speed of the trunk, measured per interface associated with it.""" + #: The minimum amount of links the trunk should consist of. iptrunk_minimum_links: int - """The minimum amount of links the trunk should consist of.""" + #: The :term:`IS-IS` metric of this link iptrunk_isis_metric: int - """The {term}`IS-IS` metric of this link""" + #: The IPv4 network used for this trunk. iptrunk_ipv4_network: ipaddress.IPv4Network - """The IPv4 network used for this trunk.""" + #: The IPv6 network used for this trunk. iptrunk_ipv6_network: ipaddress.IPv6Network - """The IPv6 network used for this trunk.""" + #: The two sides that the trunk is connected to. iptrunk_sides: IptrunkSides[IptrunkSideBlock] diff --git a/gso/products/product_blocks/router.py b/gso/products/product_blocks/router.py index 547b73c462731c577d6ce768ab0f43e18b458ee6..58cf1d3602ffe3a10bc226f71064c88286a0bbf4 100644 --- a/gso/products/product_blocks/router.py +++ b/gso/products/product_blocks/router.py @@ -1,6 +1,5 @@ -"""Product block for {class}`Router` products.""" +"""Product block for :class:`Router` products.""" import ipaddress -from typing import Optional from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum @@ -27,21 +26,21 @@ class RouterRole(strEnum): class RouterBlockInactive( ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="RouterBlock" ): - """A router that's being currently inactive. See {class}`RouterBlock`.""" - - router_fqdn: Optional[str] = None - router_ts_port: Optional[PortNumber] = None - router_access_via_ts: Optional[bool] = None - router_lo_ipv4_address: Optional[ipaddress.IPv4Address] = None - router_lo_ipv6_address: Optional[ipaddress.IPv6Address] = None - router_lo_iso_address: Optional[str] = None - router_si_ipv4_network: Optional[ipaddress.IPv4Network] = None - router_ias_lt_ipv4_network: Optional[ipaddress.IPv4Network] = None - router_ias_lt_ipv6_network: Optional[ipaddress.IPv6Network] = None - router_vendor: Optional[RouterVendor] = None - router_role: Optional[RouterRole] = None - router_site: Optional[SiteBlockInactive] - router_is_ias_connected: Optional[bool] = None + """A router that's being currently inactive. See :class:`RouterBlock`.""" + + router_fqdn: str | None = None + router_ts_port: PortNumber | None = None + router_access_via_ts: bool | None = None + router_lo_ipv4_address: ipaddress.IPv4Address | None = None + router_lo_ipv6_address: ipaddress.IPv6Address | None = None + router_lo_iso_address: str | None = None + router_si_ipv4_network: ipaddress.IPv4Network | None = None + router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None + router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None + router_vendor: RouterVendor | None = None + router_role: RouterRole | None = None + router_site: SiteBlockInactive | None + router_is_ias_connected: bool | None = None def generate_fqdn(hostname: str, site_name: str, country_code: str) -> str: @@ -49,49 +48,49 @@ def generate_fqdn(hostname: str, site_name: str, country_code: str) -> str: class RouterBlockProvisioning(RouterBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): - """A router that's being provisioned. See {class}`RouterBlock`.""" + """A router that's being provisioned. See :class:`RouterBlock`.""" router_fqdn: str router_ts_port: PortNumber - router_access_via_ts: Optional[bool] = None - router_lo_ipv4_address: Optional[ipaddress.IPv4Address] = None - router_lo_ipv6_address: Optional[ipaddress.IPv6Address] = None - router_lo_iso_address: Optional[str] = None - router_si_ipv4_network: Optional[ipaddress.IPv4Network] = None - router_ias_lt_ipv4_network: Optional[ipaddress.IPv4Network] = None - router_ias_lt_ipv6_network: Optional[ipaddress.IPv6Network] = None - router_vendor: Optional[RouterVendor] = None - router_role: Optional[RouterRole] = None - router_site: Optional[SiteBlockProvisioning] - router_is_ias_connected: Optional[bool] = None + router_access_via_ts: bool | None = None + router_lo_ipv4_address: ipaddress.IPv4Address | None = None + router_lo_ipv6_address: ipaddress.IPv6Address | None = None + router_lo_iso_address: str | None = None + router_si_ipv4_network: ipaddress.IPv4Network | None = None + router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None + router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None + router_vendor: RouterVendor | None = None + router_role: RouterRole | None = None + router_site: SiteBlockProvisioning | None + router_is_ias_connected: bool | None = None class RouterBlock(RouterBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): """A router that's currently deployed in the network.""" + #: :term:`FQDN` of a router. router_fqdn: str - """{term}`FQDN` of a router.""" + #: The port of the terminal server that this router is connected to. Used to offer out of band access. router_ts_port: PortNumber - """The port of the terminal server that this router is connected to. Used to offer out of band access.""" + #: Whether this router should be accessed through the terminal server, or through its loopback address. router_access_via_ts: bool - """Whether this router should be accessed through the terminal server, or through its loopback address.""" + #: The IPv4 loopback address of the router. router_lo_ipv4_address: ipaddress.IPv4Address - """The IPv4 loopback address of the router.""" + #: The IPv6 loopback address of the router. router_lo_ipv6_address: ipaddress.IPv6Address - """The IPv6 loopback address of the router.""" + #: The :term:`ISO` :term:`NET` of the router, used for :term:`IS-IS` support. router_lo_iso_address: str - """The {term}`ISO` {term}`NET` of the router, used for {term}`IS-IS` support.""" - router_si_ipv4_network: Optional[ipaddress.IPv4Network] - """The SI IPv4 network of the router.""" - router_ias_lt_ipv4_network: Optional[ipaddress.IPv4Network] - """The IAS LT IPv4 network of the router.""" - router_ias_lt_ipv6_network: Optional[ipaddress.IPv6Network] - """The IAS LT IPv6 network of the router.""" + #: The SI IPv4 network of the router. + router_si_ipv4_network: ipaddress.IPv4Network | None + #: The IAS LT IPv4 network of the router. + router_ias_lt_ipv4_network: ipaddress.IPv4Network | None + #: The IAS LT IPv6 network of the router. + router_ias_lt_ipv6_network: ipaddress.IPv6Network | None + #: The vendor of the router, can be any of the values defined in :class:`RouterVendor`. router_vendor: RouterVendor - """The vendor of the router, can be any of the values defined in {class}`RouterVendor`.""" + #: The role of the router, which can be any of the values defined in :class:`RouterRole`. router_role: RouterRole - """The role of the router, which can be any of the values defined in {class}`RouterRole`.""" + #: The :class:`Site` that this router resides in. Both physically and computationally. router_site: SiteBlock - """The {class}`Site` that this router resides in. Both physically and computationally.""" + #: The router is going to have a LT interface between inet0 and IAS router_is_ias_connected: bool - """The router is going to have a LT interface between inet0 and IAS""" diff --git a/gso/products/product_blocks/site.py b/gso/products/product_blocks/site.py index 4cd0f6874f97562dcf66504dc0f499d88260e1d3..74f4cf25dd1266424e222f301439a0a9f3dde694 100644 --- a/gso/products/product_blocks/site.py +++ b/gso/products/product_blocks/site.py @@ -1,6 +1,4 @@ """The product block that describes a site subscription.""" -from typing import Optional - from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum @@ -21,59 +19,59 @@ class SiteTier(strEnum): class SiteBlockInactive(ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], product_block_name="SiteBlock"): - """A site that's currently inactive, see {class}`SiteBlock`.""" + """A site that's currently inactive, see :class:`SiteBlock`.""" - site_name: Optional[str] = None - site_city: Optional[str] = None - site_country: Optional[str] = None - site_country_code: Optional[str] = None - site_latitude: Optional[LatitudeCoordinate] = None - site_longitude: Optional[LongitudeCoordinate] = None - site_internal_id: Optional[int] = None - site_bgp_community_id: Optional[int] = None - site_tier: Optional[SiteTier] = None - site_ts_address: Optional[str] = None + site_name: str | None = None + site_city: str | None = None + site_country: str | None = None + site_country_code: str | None = None + site_latitude: LatitudeCoordinate | None = None + site_longitude: LongitudeCoordinate | None = None + site_internal_id: int | None = None + site_bgp_community_id: int | None = None + site_tier: SiteTier | None = None + site_ts_address: str | None = None class SiteBlockProvisioning(SiteBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): - """A site that's currently being provisioned, see {class}`SiteBlock`.""" + """A site that's currently being provisioned, see :class:`SiteBlock`.""" - site_name: Optional[str] = None - site_city: Optional[str] = None - site_country: Optional[str] = None - site_country_code: Optional[str] = None - site_latitude: Optional[LatitudeCoordinate] = None - site_longitude: Optional[LongitudeCoordinate] = None - site_internal_id: Optional[int] = None - site_bgp_community_id: Optional[int] = None - site_tier: Optional[SiteTier] = None - site_ts_address: Optional[str] = None + site_name: str | None = None + site_city: str | None = None + site_country: str | None = None + site_country_code: str | None = None + site_latitude: LatitudeCoordinate | None = None + site_longitude: LongitudeCoordinate | None = None + site_internal_id: int | None = None + site_bgp_community_id: int | None = None + site_tier: SiteTier | None = None + site_ts_address: str | None = None class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): """A site that's currently available for routers and services to be hosted at.""" + #: The name of the site, that will dictate part of the :term:`FQDN` of routers that are hosted at this site. For + #: example: ``router.X.Y.geant.net``, where X denotes the name of the site. site_name: str - """The name of the site, that will dictate part of the {term}`FQDN` of routers that are hosted at this site. For - example: `router.X.Y.geant.net`, where X denotes the name of the site.""" + #: The city at which the site is located. site_city: str - """The city at which the site is located.""" + #: The country in which the site is located. site_country: str - """The country in which the site is located.""" + #: The code of the corresponding country. This is also used for the :term:`FQDN`, following the example given for + #: the site name, the country code would end up in the Y position. site_country_code: str - """The code of the corresponding country. This is also used for the {term}`FQDN`, following the example given for - the site name, the country code would end up in the Y position.""" + #: The latitude of the site, used for :term:`SNMP` purposes. site_latitude: LatitudeCoordinate - """The latitude of the site, used for {term}`SNMP` purposes.""" + #: Similar to the latitude, the longitude of a site. site_longitude: LongitudeCoordinate - """Similar to the latitude, the longitude of a site.""" + #: The internal ID used within GÉANT to denote a site. site_internal_id: int - """The internal ID used within GÉANT to denote a site.""" + #: The :term:`BGP` community ID of a site, used to advertise routes learned at this site. site_bgp_community_id: int - """The {term}`BGP` community ID of a site, used to advertise routes learned at this site.""" + #: The tier of a site, as described in :class:`SiteTier`. site_tier: SiteTier - """The tier of a site, as described in {class}`SiteTier`.""" - site_ts_address: Optional[str] = None - """The address of the terminal server that this router is connected to. The terminal server provides out of band - access. This is required in case a link goes down, or when a router is initially added to the network and it does - not have any IP trunks connected to it yet.""" + #: The address of the terminal server that this router is connected to. The terminal server provides out of band + #: access. This is required in case a link goes down, or when a router is initially added to the network and it + #: does not have any IP trunks connected to it yet. + site_ts_address: str | None = None diff --git a/gso/schemas/types.py b/gso/schemas/types.py index 94aced74e3c6ba4b6703b7afee01d962603cfbd4..114e573611b457a6adf3360517c3bd599021e08b 100644 --- a/gso/schemas/types.py +++ b/gso/schemas/types.py @@ -7,8 +7,8 @@ from pydantic import ConstrainedStr class LatitudeCoordinate(ConstrainedStr): """A latitude coordinate, modeled as a constrained string. - The coordinate must match the format conforming to the latitude - range of `-`90 to +90 degrees. It can be a floating-point number or an integer. + The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. It can be a + floating-point number or an integer. Valid examples: 40.7128, -74.0060, 90, -90, 0 """ @@ -26,7 +26,7 @@ class LongitudeCoordinate(ConstrainedStr): """A longitude coordinate, modeled as a constrained string. The coordinate must match the format conforming to the longitude - range of `-`180 to 180 degrees. It can be a floating point number or an integer. + range of -180 to +180 degrees. It can be a floating point number or an integer. Valid examples: 40.7128, -74.0060, 180, -180, 0 """ diff --git a/gso/services/crm.py b/gso/services/crm.py index 7568e3116eb2e685d45d1a6c67ff11d9b0bf8533..8c2af4698aa2c16478f372388df88ae6d125c308 100644 --- a/gso/services/crm.py +++ b/gso/services/crm.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any class CustomerNotFoundError(Exception): @@ -16,7 +16,7 @@ def all_customers() -> list[dict]: ] -def get_customer_by_name(name: str) -> Dict[str, Any]: +def get_customer_by_name(name: str) -> dict[str, Any]: for customer in all_customers(): if customer["name"] == name: return customer diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index 556a760953cae6cc5c55eb6f093f1ef08b9e7549..0082f9bf2525d3ba32f19a6e3918cbe32348b2de 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -20,8 +20,8 @@ class DeletionError(Exception): def _setup_connection() -> tuple[connector.Connector, IPAMParams]: """Set up a new connection with an Infoblox instance. - :return: A tuple that has an Infoblox `Connector` instance, and {term}`IPAM` parameters. - :rtype: tuple[{class}`infoblox_client.connector.Connector`, IPAMParams] + :return: A tuple that has an Infoblox ``Connector`` instance, and :term:`IPAM` parameters. + :rtype: tuple[:class:`Connector`, IPAMParams] """ oss = load_oss_params().IPAM options = { @@ -44,15 +44,15 @@ def _allocate_network( """Allocate a new network in Infoblox. The function will go over all given containers, and try to allocate a network within the available IP space. If no - space is available, this method raises an {class}`AllocationError`. + space is available, this method raises an :class:`AllocationError`. :param conn: An active Infoblox connection. - :type conn: {class}`infoblox_client.connector.Connector` + :type conn: :class:`infoblox_client.connector.Connector` :param dns_view: The Infoblox `dns_view` in which the network should be allocated. :type dns_view: str :param netmask: The netmask of the desired network. Can be up to 32 for v4 networks, and 128 for v6 networks. :type netmask: int - :param containers: A list of network containers in which the network should be allocated, given in {term}`CIDR` + :param containers: A list of network containers in which the network should be allocated, given in :term:`CIDR` notation. :type containers: list[str] :param comment: Optionally, a comment can be added to the network allocation. @@ -72,9 +72,11 @@ def _allocate_network( def hostname_available(hostname: str) -> bool: """Check whether a hostname is still available **in Infoblox**. - Check whether Infoblox already contains a {class}`infoblox_client.objects.HostRecord` that matches the given - hostname. Be aware that this method only checks within the Infoblox instance, and not the rest of the internet. - The hostname could therefore still be taken elsewhere. + Check whether Infoblox already has a :class:`infoblox_client.objects.HostRecord` that matches the given hostname. + + .. warning:: + This method only checks within the Infoblox instance, and not the rest of the internet. The hostname could + therefore still be taken elsewhere. :param hostname: The hostname to be checked. :type hostname: str @@ -86,8 +88,8 @@ def hostname_available(hostname: str) -> bool: def allocate_v4_network(service_type: str, comment: str | None = "") -> ipaddress.IPv4Network: """Allocate a new IPv4 network in Infoblox. - Allocate an IPv4 network for a specific service type. The service type should be defined in the {term}`OSS` - parameters of {term}`GSO`, from which the containers and netmask will be used. + Allocate an IPv4 network for a specific service type. The service type should be defined in the :term:`OSS` + parameters of :term:`GSO`, from which the containers and netmask will be used. :param service_type: The service type for which the network is allocated. :type service_type: str @@ -105,8 +107,8 @@ def allocate_v4_network(service_type: str, comment: str | None = "") -> ipaddres def allocate_v6_network(service_type: str, comment: str | None = "") -> ipaddress.IPv6Network: """Allocate a new IPv6 network in Infoblox. - Allocate an IPv6 network for a specific service type. The service type should be defined in the {term}`OSS` - parameters of {term}`GSO`, from which the containers and netmask will be used. + Allocate an IPv6 network for a specific service type. The service type should be defined in the :term:`OSS` + parameters of :term:`GSO`, from which the containers and netmask will be used. :param service_type: The service type for which the network is allocated. :type service_type: str @@ -122,9 +124,9 @@ def allocate_v6_network(service_type: str, comment: str | None = "") -> ipaddres def find_network_by_cidr(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> objects.Network | None: - """Find a network in Infoblox by its {term}`CIDR`. + """Find a network in Infoblox by its :term:`CIDR`. - :param ip_network: The {term}`CIDR` that is searched. + :param ip_network: The :term:`CIDR` that is searched. :type ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network """ conn, _ = _setup_connection() @@ -134,8 +136,8 @@ def find_network_by_cidr(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Netwo def delete_network(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> None: """Delete a network in Infoblox. - Delete a network that is allocated in Infoblox, by passing the {term}`CIDR` to be deleted. The {term}`CIDR` must - exactly match an existing entry in Infoblox, otherwise this method raises a {class}`DeletionError` + Delete a network that is allocated in Infoblox, by passing the :term:`CIDR` to be deleted. The :term:`CIDR` must + exactly match an existing entry in Infoblox, otherwise this method raises a :class:`DeletionError` :param ip_network: The network that should get deleted. :type ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network @@ -154,17 +156,17 @@ def allocate_host( Create a new host record in Infoblox, by providing a hostname, and the service type that is associated with this new host. Most likely to be a loopback interface. If the hostname is not available in Infoblox (due to a potential - collision) this method raises an {class}`AllocationError`. + collision) this method raises an :class:`AllocationError`. - :param hostname: The {term}`FQDN` of the new host + :param hostname: The :term:`FQDN` of the new host :type hostname: str :param service_type: The service type from which IP resources should be used. :type service_type: str - :param cname_aliases: A list of any {term}`CNAME` aliases that should be associated with this host. Most often this + :param cname_aliases: A list of any :term:`CNAME` aliases that should be associated with this host. Most often this will be a single loopback address. :type cname_aliases: list[str] :param comment: A comment that is added to the host record in Infoblox, should be the `subscription_id` of the new - {class}`Router` subscription. + :class:`Router` subscription. :type comment: str """ if not hostname_available(hostname): @@ -226,9 +228,9 @@ def find_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> o def find_host_by_fqdn(fqdn: str) -> objects.HostRecord | None: - """Find a host record by its associated {term}`FQDN`. + """Find a host record by its associated :term:`FQDN`. - :param fqdn: The {term}`FQDN` of a host that is searched for. + :param fqdn: The :term:`FQDN` of a host that is searched for. :type fqdn: str """ conn, _ = _setup_connection() @@ -239,7 +241,7 @@ def delete_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> """Delete a host from Infoblox. Delete a host record in Infoblox, by providing the IP address that is associated with the record. Raises a - {class}`DeletionError` if no record can be found in Infoblox. + :class:`DeletionError` if no record can be found in Infoblox. :param ip_addr: The IP address of the host record that should get deleted. :type ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address @@ -254,10 +256,10 @@ def delete_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> def delete_host_by_fqdn(fqdn: str) -> None: """Delete a host from Infoblox. - Delete a host record in Infoblox, by providing the {term}`FQDN` that is associated with the record. Raises a - {class}`DeletionError` if no record can be found in Infoblox. + Delete a host record in Infoblox, by providing the :term:`FQDN` that is associated with the record. Raises a + :class:`DeletionError` if no record can be found in Infoblox. - :param fqdn: The FQDN of the host record that should get deleted. + :param fqdn: The :term:`FQDN` of the host record that should get deleted. :type fqdn: str """ host = find_host_by_fqdn(fqdn) diff --git a/gso/services/netbox_client.py b/gso/services/netbox_client.py index a66aae8692b91249a1c460b5aa5d120c74df6237..45b0906b2ad28c212b995de483810c51052b02b5 100644 --- a/gso/services/netbox_client.py +++ b/gso/services/netbox_client.py @@ -7,7 +7,7 @@ from pynetbox.models.dcim import Devices, DeviceTypes, Interfaces from gso.products import Router from gso.settings import load_oss_params -from gso.utils.device_info import FEASIBLE_IP_TRUNK_LAG_RANGE, TierInfo +from gso.utils.device_info import DEFAULT_SITE, FEASIBLE_IP_TRUNK_LAG_RANGE, ROUTER_ROLE, TierInfo from gso.utils.exceptions import NotFoundError, WorkflowStateError @@ -45,7 +45,7 @@ class Site(pydantic.BaseModel): slug: str -class NetBoxClient: +class NetboxClient: """Implement all methods to communicate with the NetBox API.""" def __init__(self) -> None: @@ -67,17 +67,23 @@ class NetBoxClient: self.netbox.dcim.interfaces.filter(device_id=device.id, enabled=False, mark_connected=False, speed=speed) ) - def create_interface(self, iface_name: str, type: str, speed: str, device_name: str) -> Interfaces: + def create_interface( + self, iface_name: str, type: str, device_name: str, description: str | None = None, enabled: bool = False + ) -> Interfaces: """Create new interface on a device, where device is defined by name. - The type parameter can be 1000base-t, 10gbase-t, lag... - For more details on type definition have a look in - choices.py in the netbox API implementation in module DCIM. + The type parameter can be 1000base-t, 10gbase-t, lag, etc. + For more details on type definition have a look in choices.py in the netbox API implementation in module DCIM. Returns the new interface object as dict. """ device = self.get_device_by_name(device_name) return self.netbox.dcim.interfaces.create( - name=iface_name, type=type, speed=speed, enabled=False, mark_connected=False, device=device.id + name=iface_name, + type=type, + enabled=enabled, + mark_connected=False, + device=device.id, + description=description, ) def create_device_type(self, manufacturer: str, model: str, slug: str) -> DeviceTypes: @@ -100,6 +106,15 @@ class NetBoxClient: device_manufacturer = Manufacturer(**{"name": name, "slug": slug}) return self.netbox.dcim.manufacturers.create(dict(device_manufacturer)) + @staticmethod + def calculate_interface_speed(interface: Interfaces) -> int | None: + """Calculate the interface speed in bits per second.""" + + type_parts = interface.type.value.split("-") + if "gbase" in type_parts[0]: + return int("".join(filter(str.isdigit, type_parts[0]))) * 1000000 + return None + def create_device(self, router_name: str, site_tier: str) -> Devices: """Create a new device in netbox.""" @@ -108,10 +123,10 @@ class NetBoxClient: device_type = self.netbox.dcim.device_types.get(model=tier_info.device_type) # Get device role id - device_role = self.netbox.dcim.device_roles.get(name="router") + device_role = self.netbox.dcim.device_roles.get(name=ROUTER_ROLE["name"]) # Get site id - device_site = self.netbox.dcim.sites.get(name="Amsterdam") + device_site = self.netbox.dcim.sites.get(name=DEFAULT_SITE["name"]) # Create new device device = self.netbox.dcim.devices.create( @@ -119,40 +134,54 @@ class NetBoxClient: ) module_bays = list(self.netbox.dcim.module_bays.filter(device_id=device.id)) card_type = self.netbox.dcim.module_types.get(model=tier_info.module_type) - for module_bay in module_bays: + valid_module_bays = [bay for bay in module_bays if int(bay.position) in tier_info.module_bays_slots] + for module_bay in valid_module_bays: self.netbox.dcim.modules.create( device=device.id, module_bay=module_bay.id, module_type=card_type.id, status="active", + enabled=False, comments="Installed via pynetbox", ) + for interface in self.netbox.dcim.interfaces.filter(device_id=device.id): + interface.speed = self.calculate_interface_speed(interface) + interface.enabled = False + interface.save() + return device def delete_device(self, router_name: str) -> None: self.netbox.dcim.devices.get(name=router_name).delete() return - def attach_interface_to_lag(self, device_name: str, lag_name: str, iface_name: str) -> Interfaces: - """Assign a given interface to a lag. + def attach_interface_to_lag( + self, device_name: str, lag_name: str, iface_name: str, description: str | None = None + ) -> Interfaces: + """Assign a given interface to a LAG. - Returns the lag object with the assignend interfaces + Returns the interface object after assignment. """ # Get device id device = self.get_device_by_name(device_name) - # Now get interface for device + # Get interface for device iface = self.netbox.dcim.interfaces.get(name=iface_name, device_id=device.id) - # Get lag + # Get LAG lag = self.netbox.dcim.interfaces.get(name=lag_name, device_id=device.id) - # Assign interface to lag + # Assign interface to LAG iface.lag = lag.id - # Update interface - return self.netbox.dcim.interfaces.update(iface) + # Set description if provided + if description: + iface.description = description + + iface.save() + + return iface def reserve_interface(self, device_name: str, iface_name: str) -> Interfaces: """Reserve an interface by enabling it.""" @@ -203,7 +232,7 @@ class NetBoxClient: # Get the existing lag interfaces for the device lag_interface_names = [ - interface["name"] for interface in self.netbox.dcim.interfaces.filter(device_name=device.id, type="lag") + interface["name"] for interface in self.netbox.dcim.interfaces.filter(device=device.name, type="lag") ] # Generate all feasible lags @@ -211,3 +240,21 @@ class NetBoxClient: # Return available lags not assigned to the device return [lag for lag in all_feasible_lags if lag not in lag_interface_names] + + @staticmethod + def calculate_speed_bits_per_sec(speed: str) -> int: + """Extract the numeric part from the speed.""" + + numeric_part = int("".join(filter(str.isdigit, speed))) + # Convert to bits per second + return numeric_part * 1000000 + + def get_available_interfaces(self, router_id: UUID, speed: str) -> Interfaces: + """Return all available interfaces of a device filtered by speed.""" + + router = Router.from_subscription(router_id).router.router_fqdn + device = self.get_device_by_name(router) + speed_bps = self.calculate_speed_bits_per_sec(speed) + return self.netbox.dcim.interfaces.filter( + device=device.name, enabled=False, mark_connected=False, speed=speed_bps + ) diff --git a/gso/services/provisioning_proxy.py b/gso/services/provisioning_proxy.py index f3e18eb8503b15fe8e7e1bc561548811d0bd8f4c..5a6ba11d79fedcfa8431d1dc0c8a283e23c9d73e 100644 --- a/gso/services/provisioning_proxy.py +++ b/gso/services/provisioning_proxy.py @@ -1,6 +1,6 @@ -"""The Provisioning Proxy service, which interacts with {term}`LSO` running externally. +"""The Provisioning Proxy service, which interacts with :term:`LSO` running externally. -{term}`LSO` is responsible for executing Ansible playbooks, that deploy subscriptions. +:term:`LSO` is responsible for executing Ansible playbooks, that deploy subscriptions. """ import json import logging @@ -26,7 +26,7 @@ DEFAULT_LABEL = "Provisioning proxy is running. Please come back later for the r class CUDOperation(strEnum): - """Enumerator for different {term}`CRUD` operations that the provisioning proxy supports. + """Enumerator for different :term:`CRUD` operations that the provisioning proxy supports. Read is not applicable, hence the missing R. """ @@ -37,9 +37,9 @@ class CUDOperation(strEnum): def _send_request(endpoint: str, parameters: dict, process_id: UUIDstr, operation: CUDOperation) -> None: - """Send a request to {term}`LSO`. The callback address is derived using the process ID provided. + """Send a request to :term:`LSO`. The callback address is derived using the process ID provided. - :param endpoint: The {term}`LSO`-specific endpoint to call, depending on the type of service object that's acted + :param endpoint: The :term:`LSO`-specific endpoint to call, depending on the type of service object that's acted upon. :type endpoint: str :param parameters: JSON body for the request, which will almost always at least consist of a subscription object, @@ -49,7 +49,7 @@ def _send_request(endpoint: str, parameters: dict, process_id: UUIDstr, operatio playbook is completed. :type process_id: UUIDstr :param operation: The specific operation that's performed with the request. - :type operation: {class}`CUDOperation` + :type operation: :class:`CUDOperation` :rtype: None """ oss = settings.load_oss_params() @@ -82,10 +82,10 @@ def _send_request(endpoint: str, parameters: dict, process_id: UUIDstr, operatio def provision_router( subscription: RouterProvisioning, process_id: UUIDstr, tt_number: str, dry_run: bool = True ) -> None: - """Provision a new router using {term}`LSO`. + """Provision a new router using :term:`LSO`. :param subscription: The subscription object that's to be provisioned. - :type subscription: {class}`RouterProvisioning` + :type subscription: :class:`RouterProvisioning` :param process_id: The related process ID, used for callback. :type process_id: UUIDstr :param dry_run: A boolean indicating whether this should be a dry run or not, defaults to `True`. @@ -105,10 +105,10 @@ def provision_router( def provision_ip_trunk( subscription: IptrunkProvisioning, process_id: UUIDstr, tt_number: str, config_object: str, dry_run: bool = True ) -> None: - """Provision an IP trunk service using {term}`LSO`. + """Provision an IP trunk service using :term:`LSO`. :param subscription: The subscription object that's to be provisioned. - :type subscription: {class}`IptrunkProvisioning` + :type subscription: :class:`IptrunkProvisioning` :param process_id: The related process ID, used for callback. :type process_id: UUIDstr :param config_object: The type of object that's deployed. @@ -130,10 +130,10 @@ def provision_ip_trunk( def check_ip_trunk(subscription: IptrunkProvisioning, process_id: UUIDstr, tt_number: str, check_name: str) -> None: - """Provision an IP trunk service using {term}`LSO`. + """Provision an IP trunk service using :term:`LSO`. :param subscription: The subscription object that's to be provisioned. - :type subscription: {class}`IptrunkProvisioning` + :type subscription: :class:`IptrunkProvisioning` :param process_id: The related process ID, used for callback. :type process_id: UUIDstr :param check_name: The name of the check to execute @@ -150,10 +150,10 @@ def check_ip_trunk(subscription: IptrunkProvisioning, process_id: UUIDstr, tt_nu def deprovision_ip_trunk(subscription: Iptrunk, process_id: UUIDstr, tt_number: str, dry_run: bool = True) -> None: - """Deprovision an IP trunk service using {term}`LSO`. + """Deprovision an IP trunk service using :term:`LSO`. :param subscription: The subscription object that's to be provisioned. - :type subscription: {class}`IptrunkProvisioning` + :type subscription: :class:`IptrunkProvisioning` :param process_id: The related process ID, used for callback. :type process_id: UUIDstr :param dry_run: A boolean indicating whether this should be a dry run or not, defaults to `True`. @@ -183,15 +183,15 @@ def migrate_ip_trunk( config_object: str, dry_run: bool = True, ) -> None: - """Migrate an IP trunk service using {term}`LSO`. + """Migrate an IP trunk service using :term:`LSO`. :param subscription: The subscription object that's to be migrated. - :type subscription: {class}`Iptrunk` + :type subscription: :class:`Iptrunk` :param new_node: The new node that is being migrated to - :type new_node: {class}`Router` + :type new_node: :class:`Router` :param new_lag_interface: The name of the new aggregated Ethernet interface :type new_lag_interface: str - :param new_lag_member_interfaces: The new list of interfaces that are part of the {term}`LAG` + :param new_lag_member_interfaces: The new list of interfaces that are part of the :term:`LAG` :type new_lag_member_interfaces: list[str] :param replace_index: The index of the side that is going to be replaced as part of the existing trunk, can be `0` or `1`. @@ -233,13 +233,13 @@ def _await_pp_results(subscription: SubscriptionModel, label_text: str = DEFAULT output of the provisioning proxy. :param subscription: The current subscription that the provisioning proxy is acting on. - :type subscription: {class}`orchestrator.domain.SubscriptionModel` + :type subscription: :class:`orchestrator.domain.SubscriptionModel` :param label_text: A label that's displayed to the operator when the provisioning proxy has not returned its results yet. Defaults to `DEFAULT_LABEL`. :type label_text: str :return: The input that's given by the provisioning proxy, that should contain run results, and a `confirm` boolean set to `True`. - :rtype: {class}`orchestrator.types.FormGenerator` + :rtype: :class:`orchestrator.types.FormGenerator` """ class ProvisioningResultPage(FormPage): @@ -266,7 +266,7 @@ def _reset_pp_success_state() -> State: """Reset the boolean that indicates a successful provisioning proxy result in the state of a running workflow. :return: A new state of the workflow, where the key `pp_did_succeed` has been (re)set to false. - :rtype: {class}`orchestrator.types.State` + :rtype: :class:`orchestrator.types.State` """ return {"pp_did_succeed": False} @@ -280,14 +280,20 @@ def _confirm_pp_results(state: State) -> FormGenerator: execution will be retried. This will happen up to two times, after which the workflow will fail. :param state: The current state of the workflow. - :type state: {class}`orchestrator.types.State` + :type state: :class:`orchestrator.types.State` :return: Confirmation from the user, when presented with the run results. - :rtype: {class}`orchestrator.types.FormGenerator` + :rtype: :class:`orchestrator.types.FormGenerator` """ if "pp_run_results" not in state: # FIXME: dirty hack that makes the skipping """work""" return {"pp_did_succeed": True} + class ContinueForm(FormPage): + class Config: + title = "Continue to see the result?" + + yield ContinueForm + successful_run = state["pp_run_results"]["return_code"] == 0 class ConfirmRunPage(FormPage): @@ -322,7 +328,7 @@ def pp_interaction(provisioning_step: Step, attempts: int, abort_on_failure: boo - The input step that suspends the workflow, and will wait for results from the provisioning proxy. - An input step that presents the user with the results, where they must be confirmed. - All these steps are wrapped in a {class}`orchestrator.workflow.conditional`. This ensures that when provisioning was + All these steps are wrapped in a :class:`orchestrator.workflow.conditional`. This ensures that when provisioning was already successful, these steps are skipped. This mechanism is quite a dirty hack, and it's planned to be addressed in a later release. @@ -330,13 +336,13 @@ def pp_interaction(provisioning_step: Step, attempts: int, abort_on_failure: boo it's still not successful, the workflow will be aborted if so indicated with the `abort_on_failure` boolean. :param provisioning_step: The step that executes an interaction with the provisioning proxy. - :type provisioning_step: {class}`orchestrator.workflow.Step` + :type provisioning_step: :class:`orchestrator.workflow.Step` :param attempts: The maximum amount of times that a provisioning can be retried. :type attempts: int :param abort_on_failure: A boolean value that indicates whether a workflow should abort if the provisioning has failed the maximum amount of tries. Defaults to `True`. :return: A list of three steps that form one interaction with the provisioning proxy. - :rtype: {class}`orchestrator.workflow.StepList` + :rtype: :class:`orchestrator.workflow.StepList` """ should_retry_pp_steps = conditional(lambda state: not state.get("pp_did_succeed")) diff --git a/gso/services/resource_manager.py b/gso/services/resource_manager.py deleted file mode 100644 index 88ef5e189e29382fca6b92a258d5f62de0cf6d72..0000000000000000000000000000000000000000 --- a/gso/services/resource_manager.py +++ /dev/null @@ -1,126 +0,0 @@ -# mypy: ignore-errors -from enum import Enum, auto -from typing import List - -from gso import settings - -# TODO -# - fill in the implementations -# - consider the extra API methods -# - decided what to do with various error conditions (currently assertions) - - -class InterfaceAllocationState(Enum): - AVAILABLE = auto() - RESERVED = auto() - ALLOCATED = auto() - - -def _dummy_router_interfaces(): - return { - "lags": [], - "physical": [{"name": f"ifc-{x}", "state": InterfaceAllocationState.AVAILABLE} for x in range(250)], - } - - -_DUMMY_INVENTORY = { - "fqdn-a": _dummy_router_interfaces(), - "fqdn-b": _dummy_router_interfaces(), - "fqdn-c": _dummy_router_interfaces(), - "fqdn-d": _dummy_router_interfaces(), -} - - -def import_new_router(new_router_fqdn: str, subscription_id: str, oss_params=settings.OSSParams): - # TODO: this is a dummy implementation - - # TODO: specifiy if this should be an error (and if now, what it means) - assert new_router_fqdn not in _DUMMY_INVENTORY - _DUMMY_INVENTORY[new_router_fqdn] = _dummy_router_interfaces() - - -def next_lag(router_fqdn: str, subscription_id: str, oss_params=settings.OSSParams) -> str: - # TODO: this is a dummy implementation - - assert router_fqdn in _DUMMY_INVENTORY - - lag_idx = 0 - while True: - lag_name = f"ae-{lag_idx}" - if lag_name not in _DUMMY_INVENTORY[router_fqdn]["lags"]: - _DUMMY_INVENTORY[router_fqdn]["lags"].append(lag_name) - return lag_name - lag_idx += 1 - - -def available_physical_interfaces(router_fqdn: str, oss_params=settings.OSSParams) -> List[str]: - # TODO: this is a dummy implementation - - assert router_fqdn in _DUMMY_INVENTORY - - return [ - ifc["name"] - for ifc in _DUMMY_INVENTORY[router_fqdn]["physical"] - if ifc["state"] == InterfaceAllocationState.AVAILABLE - ] - - -def _find_physical(router_fqdn: str, interface_name: str) -> dict: - assert router_fqdn in _DUMMY_INVENTORY - for ifc in _DUMMY_INVENTORY[router_fqdn]["physical"]: - if ifc["name"] == interface_name: - return ifc - raise AssertionError(f"interface {interface_name} not found on {router_fqdn}") - - -def reserve_physical_interface( - router_fqdn: str, interface_name: str, subscription_id: str, oss_params=settings.OSSParams -): - # TODO: this is a dummy implementation - - ifc = _find_physical(router_fqdn, interface_name) - assert ( - ifc["state"] == InterfaceAllocationState.AVAILABLE - ), f"interface {router_fqdn}:{interface_name} is not available" - ifc["state"] = InterfaceAllocationState.RESERVED - - -def allocate_physical_interface(router_fqdn: str, interface_name: str, oss_params=settings.OSSParams): - # TODO: this is a dummy implementation - - ifc = _find_physical(router_fqdn, interface_name) - # TODO: is there a use case for moving - # directly from AVAILABLE to ALLOCATED? - assert ( - ifc["state"] == InterfaceAllocationState.RESERVED - ), f"interface {router_fqdn}:{interface_name} is not reserved" - ifc["state"] = InterfaceAllocationState.RESERVED - - -def free_physical_interface(router_fqdn: str, interface_name: str, oss_params=settings.OSSParams): - # TODO: this is a dummy implementation - - ifc = _find_physical(router_fqdn, interface_name) - # TODO: is this truly an error that should be handled? - # or is it ok to ignore this? - assert ( - ifc["state"] != InterfaceAllocationState.AVAILABLE - ), f"interface {router_fqdn}:{interface_name} is already available" - ifc["state"] = InterfaceAllocationState.AVAILABLE - - -def free_lag(router_fqdn: str, lag_name: str, oss_params=settings.OSSParams): - # TODO: is this a use case that should be handled? - # e.g. who keeps track of the bundled physical interfaces? - pass - - -def remove_router(router_fqdn: str, oss_params=settings.OSSParams): - # TODO: is this a use case that should be handled? - pass - - -def all_lags(router_fqdn: str, oss_params=settings.OSSParams) -> List[str]: - # TODO: is this a use case that should be handled? - assert router_fqdn in _DUMMY_INVENTORY - return _DUMMY_INVENTORY[router_fqdn]["lags"] diff --git a/gso/services/subscriptions.py b/gso/services/subscriptions.py index 97c709647f04f35338147cbbc7ae09774c03442c..5e234813f34dba4818da1e7504e0d33a023070ab 100644 --- a/gso/services/subscriptions.py +++ b/gso/services/subscriptions.py @@ -18,14 +18,13 @@ def get_active_subscriptions( ) -> list[Subscription]: """Retrieve active subscriptions for a specific product type. - Args: - ---- - product_type (str): The type of the product for which to retrieve subscriptions. - fields (list[str]): List of fields to be included in the returned Subscription objects. - - Returns: - ------- - list[Subscription]: A list of Subscription objects that match the query. + :param product_type: The type of the product for which to retrieve subscriptions. + :type product_type: str + :param fields: List of fields to be included in the returned Subscription objects. + :type fields: list[str] + + :return: A list of Subscription objects that match the query. + :rtype: list[Subscription] """ dynamic_fields = [getattr(SubscriptionTable, field) for field in fields] @@ -43,13 +42,11 @@ def get_active_subscriptions( def get_active_site_subscriptions(fields: list[str]) -> list[Subscription]: """Retrieve active subscriptions specifically for sites. - Args: - ---- - fields (list[str]): The fields to be included in the returned Subscription objects. + :param fields: The fields to be included in the returned Subscription objects. + :type fields: list[str] - Returns: - ------- - list[Subscription]: A list of Subscription objects for sites. + :return: A list of Subscription objects for sites. + :rtype: list[Subscription] """ return get_active_subscriptions(ProductType.SITE, fields) @@ -57,27 +54,23 @@ def get_active_site_subscriptions(fields: list[str]) -> list[Subscription]: def get_active_router_subscriptions(fields: list[str]) -> list[Subscription]: """Retrieve active subscriptions specifically for routers. - Args: - ---- - fields (list[str]): The fields to be included in the returned Subscription objects. + :param fields: The fields to be included in the returned Subscription objects. + :type fields: list[str] - Returns: - ------- - list[Subscription]: A list of Subscription objects for routers. + :return: A list of Subscription objects for routers. + :rtype: list[Subscription] """ return get_active_subscriptions(product_type=ProductType.ROUTER, fields=fields) def get_product_id_by_name(product_name: ProductType) -> UUID: - """Retrieve the {term}`UUID` of a product by its name. + """Retrieve the :term:`UUID` of a product by its name. - Args: - ---- - product_name (ProductType): The name of the product. + :param product_name: The name of the product. + :type product_name: ProductType - Returns: - ------- - {term}`UUID`: The {term}`UUID` of the product. + :return UUID: The :term:`UUID` of the product. + :rtype: UUID """ return ProductTable.query.filter_by(name=product_name).first().product_id @@ -85,13 +78,11 @@ def get_product_id_by_name(product_name: ProductType) -> UUID: def get_active_site_subscription_by_name(site_name: str) -> Subscription: """Retrieve an active subscription for a site by the site's name. - Args: - ---- - site_name (str): The name of the site for which to retrieve the subscription. + :param site_name: The name of the site for which to retrieve the subscription. + :type site_name: str - Returns: - ------- - Subscription: The Subscription object for the site. + :return: The Subscription object for the site. + :rtype: Subscription """ return ( SubscriptionTable.query.join(ProductTable) diff --git a/gso/settings.py b/gso/settings.py index f6ecc5e08e5cf9e45485b1931b2719661d56fc36..701f65fddc74029e2fba309d76f5ec275fd1d9e8 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -1,4 +1,4 @@ -"""{term}`GSO` settings. +""":term:`GSO` settings. Ensuring that the required parameters are set correctly. """ @@ -14,10 +14,10 @@ logger = logging.getLogger(__name__) class GeneralParams(BaseSettings): - """General parameters for a {term}`GSO` configuration file.""" + """General parameters for a :term:`GSO` configuration file.""" public_hostname: str - """The hostname that {term}`GSO` is publicly served at, used for building the callback URL that the provisioning + """The hostname that :term:`GSO` is publicly served at, used for building the callback URL that the provisioning proxy uses.""" @@ -60,7 +60,7 @@ class ServiceNetworkParams(BaseSettings): class IPAMParams(BaseSettings): - """A set of parameters related to {term}`IPAM`.""" + """A set of parameters related to :term:`IPAM`.""" INFOBLOX: InfoBloxParams LO: ServiceNetworkParams @@ -87,7 +87,7 @@ class NetBoxParams(BaseSettings): class OSSParams(BaseSettings): - """The set of parameters required for running {term}`GSO`.""" + """The set of parameters required for running :term:`GSO`.""" GENERAL: GeneralParams IPAM: IPAMParams diff --git a/gso/utils/device_info.py b/gso/utils/device_info.py index 1c193557f8c21515103b210517f4c1e5ab58a597..5a139889229efd80918e45079c805f18461e9dbb 100644 --- a/gso/utils/device_info.py +++ b/gso/utils/device_info.py @@ -12,16 +12,16 @@ class ModuleInfo(BaseModel): class TierInfo: def __init__(self) -> None: self.Tier1 = ModuleInfo( - device_type="7750-SR7s", + device_type="7750 SR-7s", module_bays_slots=[1, 2], - module_type="XCM2s-XMA2s-36p-800g", + module_type="XMA2-s-36p-400g", breakout_interfaces_per_slot=[36, 35, 34, 33], total_10g_interfaces=80, ) self.Tier2 = ModuleInfo( device_type="7750-SR7s", module_bays_slots=[1, 2], - module_type="XCM2s-XMA2s-36p-400g", + module_type="XMA2-s-36p-400g", breakout_interfaces_per_slot=[36, 35, 34, 33], total_10g_interfaces=60, ) @@ -30,4 +30,9 @@ class TierInfo: return getattr(self, name) -FEASIBLE_IP_TRUNK_LAG_RANGE = range(1, 10) +# The range includes values from 1 to 10 (11 is not included) +FEASIBLE_IP_TRUNK_LAG_RANGE = range(1, 11) + +# Define default values +ROUTER_ROLE = {"name": "router", "slug": "router"} +DEFAULT_SITE = {"name": "GEANT", "slug": "geant"} diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py index 32ffca79cc43d7e566515c88ee31206f029ac5f4..65a84b5d64e63f09074d2da0e7cd9660322e92ae 100644 --- a/gso/workflows/__init__.py +++ b/gso/workflows/__init__.py @@ -1,4 +1,4 @@ -"""Initialisation class that imports all workflows into {term}`GSO`.""" +"""Initialisation class that imports all workflows into :term:`GSO`.""" from orchestrator.workflows import LazyWorkflowInstance LazyWorkflowInstance("gso.workflows.iptrunk.create_iptrunk", "create_iptrunk") diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index ae2c8cf9301e1e634d3695c8b5078c834705970d..b08da1292fc6f34459a8af0419cc14d21c251eb6 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -1,18 +1,28 @@ from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice, UniqueConstrainedList +from orchestrator.forms.validators import Choice, ChoiceList, UniqueConstrainedList from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form +from pydantic import validator +from pynetbox.models.dcim import Interfaces from gso.products.product_blocks import PhyPortCapacity from gso.products.product_blocks.iptrunk import IptrunkType +from gso.products.product_blocks.router import RouterVendor from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning from gso.products.product_types.router import Router from gso.services import infoblox, provisioning_proxy, subscriptions +from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import pp_interaction -from gso.workflows.utils import customer_selector +from gso.workflows.utils import ( + available_interfaces_choices, + available_lags_choices, + customer_selector, + get_router_vendor, + validate_router_in_netbox, +) def initial_input_form_generator(product_name: str) -> FormGenerator: @@ -39,28 +49,73 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: initial_user_input = yield CreateIptrunkForm - class AeMembersListA(UniqueConstrainedList[str]): + router_enum_a = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore + + class SelectRouterSideA(FormPage): + class Config: + title = "Select a router for side A of the trunk." + + iptrunk_sideA_node_id: router_enum_a # type: ignore[valid-type] + + @validator("iptrunk_sideA_node_id", allow_reuse=True) + def validate_device_exists_in_netbox(cls, iptrunk_sideA_node_id: UUIDstr) -> str | None: + return validate_router_in_netbox(iptrunk_sideA_node_id) + + user_input_router_side_a = yield SelectRouterSideA + router_a = user_input_router_side_a.iptrunk_sideA_node_id.name + side_a_ae_iface = available_lags_choices(router_a) or str + + class AeMembersListA(ChoiceList): + min_items = initial_user_input.iptrunk_minimum_links + item_type = available_interfaces_choices(router_a, initial_user_input.iptrunk_speed) # type: ignore + unique_items = True + + class JuniperAeMembers(UniqueConstrainedList[str]): min_items = initial_user_input.iptrunk_minimum_links + unique_items = True - RouterEnumA = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] + ae_members_side_a = AeMembersListA if get_router_vendor(router_a) == RouterVendor.NOKIA else JuniperAeMembers + + class AeMembersDescriptionListA(UniqueConstrainedList[str]): + min_items = initial_user_input.iptrunk_minimum_links class CreateIptrunkSideAForm(FormPage): class Config: title = "Provide subscription details for side A of the trunk." - iptrunk_sideA_node_id: RouterEnumA # type: ignore[valid-type] - iptrunk_sideA_ae_iface: str + iptrunk_sideA_ae_iface: side_a_ae_iface # type: ignore[valid-type] iptrunk_sideA_ae_geant_a_sid: str - iptrunk_sideA_ae_members: AeMembersListA - iptrunk_sideA_ae_members_descriptions: AeMembersListA + iptrunk_sideA_ae_members: ae_members_side_a # type: ignore[valid-type] + iptrunk_sideA_ae_members_descriptions: AeMembersDescriptionListA user_input_side_a = yield CreateIptrunkSideAForm - # Remove the selected router for side A, to prevent any loops - routers.pop(str(user_input_side_a.iptrunk_sideA_node_id.name)) - RouterEnumB = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore[arg-type] + routers.pop(str(user_input_router_side_a.iptrunk_sideA_node_id.name)) + router_enum_b = Choice("Select a router", zip(routers.keys(), routers.items())) # type: ignore + + class SelectRouterSideB(FormPage): + class Config: + title = "Select a router for side B of the trunk." + + iptrunk_sideB_node_id: router_enum_b # type: ignore[valid-type] + + @validator("iptrunk_sideB_node_id", allow_reuse=True) + def validate_device_exists_in_netbox(cls, iptrunk_sideB_node_id: UUIDstr) -> str | None: + return validate_router_in_netbox(iptrunk_sideB_node_id) + + user_input_router_side_b = yield SelectRouterSideB + router_b = user_input_router_side_b.iptrunk_sideB_node_id.name + side_b_ae_iface = available_lags_choices(router_b) or str - class AeMembersListB(UniqueConstrainedList[str]): + class AeMembersListB(ChoiceList): + min_items = len(user_input_side_a.iptrunk_sideA_ae_members) + max_items = len(user_input_side_a.iptrunk_sideA_ae_members) + item_type = available_interfaces_choices(router_b, initial_user_input.iptrunk_speed) # type: ignore + unique_items = True + + ae_members_side_b = AeMembersListB if get_router_vendor(router_b) == RouterVendor.NOKIA else JuniperAeMembers + + class AeMembersDescriptionListB(UniqueConstrainedList[str]): min_items = len(user_input_side_a.iptrunk_sideA_ae_members) max_items = len(user_input_side_a.iptrunk_sideA_ae_members) @@ -68,15 +123,20 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: class Config: title = "Provide subscription details for side B of the trunk." - iptrunk_sideB_node_id: RouterEnumB # type: ignore[valid-type] - iptrunk_sideB_ae_iface: str + iptrunk_sideB_ae_iface: side_b_ae_iface # type: ignore[valid-type] iptrunk_sideB_ae_geant_a_sid: str - iptrunk_sideB_ae_members: AeMembersListB - iptrunk_sideB_ae_members_descriptions: AeMembersListB + iptrunk_sideB_ae_members: ae_members_side_b # type: ignore[valid-type] + iptrunk_sideB_ae_members_descriptions: AeMembersDescriptionListB user_input_side_b = yield CreateIptrunkSideBForm - return initial_user_input.dict() | user_input_side_a.dict() | user_input_side_b.dict() + return ( + initial_user_input.dict() + | user_input_router_side_a.dict() + | user_input_side_a.dict() + | user_input_router_side_b.dict() + | user_input_side_b.dict() + ) @step("Create subscription") @@ -161,7 +221,7 @@ def provision_ip_trunk_iface_real(subscription: IptrunkProvisioning, process_id: return { "subscription": subscription, - "label_text": "[COMMIT] Provisioning a trunk interface, please refresh to get the results of the playbook.", + "label_text": "Provisioning a trunk interface, please refresh to get the results of the playbook.", } @@ -205,6 +265,57 @@ def check_ip_trunk_isis(subscription: IptrunkProvisioning, process_id: UUIDstr, } +@step("NextBox integration") +def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: + """Create the LAG interfaces in NetBox and attach the lag interfaces to the physical interfaces.""" + + nbclient = NetboxClient() + for side in range(0, 2): + if subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_vendor == RouterVendor.NOKIA: + # Create LAG interfaces + lag_interface: Interfaces = nbclient.create_interface( + iface_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_iface, + type="lag", + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + description=str(subscription.subscription_id), + enabled=True, + ) + # Attach physical interfaces to LAG + # Update interface description to subscription ID + # Reserve interfaces + for interface in subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_members: + nbclient.attach_interface_to_lag( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + lag_name=lag_interface.name, + iface_name=interface, + description=str(subscription.subscription_id), + ) + nbclient.reserve_interface( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + iface_name=interface, + ) + return { + "subscription": subscription, + "label_text": "NextBox integration: Reserved interfaces.", + } + + +@step("Allocate interfaces in Netbox") +def allocate_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: + """Allocate the LAG interfaces in NetBox and attach the lag interfaces to the physical interfaces.""" + for side in range(0, 2): + if subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_vendor == RouterVendor.NOKIA: + for interface in subscription.iptrunk.iptrunk_sides[side].iptrunk_side_ae_members: + NetboxClient().allocate_interface( + device_name=subscription.iptrunk.iptrunk_sides[side].iptrunk_side_node.router_fqdn, + iface_name=interface, + ) + return { + "subscription": subscription, + "label_text": "NextBox integration: Allocated interfaces.", + } + + @workflow( "Create IP trunk", initial_input_form=wrap_create_initial_input_form(initial_input_form_generator), @@ -217,12 +328,14 @@ def create_iptrunk() -> StepList: >> store_process_subscription(Target.CREATE) >> initialize_subscription >> get_info_from_ipam + >> reserve_interfaces_in_netbox >> pp_interaction(provision_ip_trunk_iface_dry, 3) >> pp_interaction(provision_ip_trunk_iface_real, 3) >> pp_interaction(check_ip_trunk_connectivity, 2, False) >> pp_interaction(provision_ip_trunk_isis_iface_dry, 3) >> pp_interaction(provision_ip_trunk_isis_iface_real, 3) >> pp_interaction(check_ip_trunk_isis, 2, False) + >> allocate_interfaces_in_netbox >> set_status(SubscriptionLifecycle.ACTIVE) >> resync >> done diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index b492606b5641274d94f26bb90ef88f9152af9ceb..3b2c1b4d160dcfa3a51815c6b1b898f7e38e4dfb 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -1,6 +1,6 @@ import re from logging import getLogger -from typing import NoReturn, Optional +from typing import NoReturn from orchestrator import step, workflow from orchestrator.config.assignee import Assignee @@ -47,7 +47,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: tt_number: str replace_side: ReplacedSide # type: ignore[valid-type] warning_label: Label = "Are we moving to a different Site?" # type: ignore[assignment] - migrate_to_different_site: Optional[bool] = False + migrate_to_different_site: bool | None = False old_side_input = yield OldSideIptrunkForm @@ -285,19 +285,6 @@ def deploy_new_isis( } -# Leaving it like this as a placeholder -# @step("Check ISIS metric") -# def check_isis(subscription: Iptrunk, process_id: UUIDstr) -> State: -# provisioning_proxy.check_ip_trunk(subscription, process_id, "VERB NEEDS TO BE UPDATED") -# -# logger.warning("Playbook verb is not yet properly set.") -# -# return { -# "subscription": subscription, -# "label_text": "Checking ISIS functionality, please refresh to get the results of the playbook.", -# } - - @inputstep("Wait for confirmation", assignee=Assignee.SYSTEM) def confirm_continue_restore_isis() -> FormGenerator: class ProvisioningResultPage(FormPage): diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 2d28c06e64879a28f3a2d031a42e7a3771705675..91e7e82a3355af50e3159a1e9dc4fa524db83aff 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -16,7 +16,7 @@ from gso.products.product_types.router import RouterInactive, RouterProvisioning from gso.products.product_types.site import Site from gso.products.shared import PortNumber from gso.services import infoblox, provisioning_proxy, subscriptions -from gso.services.netbox_client import NetBoxClient +from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import pp_interaction from gso.workflows.utils import customer_selector, iso_from_ipv4 @@ -157,11 +157,12 @@ def provision_router_real(subscription: RouterProvisioning, process_id: UUIDstr, @step("Create NetBox Device") def create_netbox_device(subscription: RouterProvisioning) -> State: - NetBoxClient().create_device( - subscription.router.router_fqdn, subscription.router.router_site.site_tier # type: ignore - ) - - return {"subscription": subscription} + if subscription.router.router_vendor == RouterVendor.NOKIA: + NetboxClient().create_device( + subscription.router.router_fqdn, subscription.router.router_site.site_tier # type: ignore + ) + return {"subscription": subscription, "label_text": "Creating NetBox device"} + return {"subscription": subscription, "label_text": "Skipping NetBox device creation for Juniper router."} @step("Verify IPAM resources for loopback interface") diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py index b02f90cae1f328e148440ffc3f146d29cb1aa198..4989823fc1d94ddd883654ae187753abca493f0a 100644 --- a/gso/workflows/router/terminate_router.py +++ b/gso/workflows/router/terminate_router.py @@ -9,9 +9,10 @@ from orchestrator.workflow import StepList, conditional, done, init, step, workf 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_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.netbox_client import NetboxClient logger = logging.getLogger(__name__) @@ -61,7 +62,8 @@ def remove_config_from_router() -> None: @step("Remove Device from NetBox") def remove_device_from_netbox(subscription: Router) -> dict[str, Router]: - NetBoxClient().delete_device(subscription.router.router_fqdn) + if subscription.router.router_vendor == RouterVendor.NOKIA: + NetboxClient().delete_device(subscription.router.router_fqdn) return {"subscription": subscription} diff --git a/gso/workflows/tasks/import_router.py b/gso/workflows/tasks/import_router.py index 4d157709cb8f1b3b2e9a391e27af0f8278ad6c93..0821e31414c822197cb419101562a4d74a5afaec 100644 --- a/gso/workflows/tasks/import_router.py +++ b/gso/workflows/tasks/import_router.py @@ -1,5 +1,4 @@ import ipaddress -from typing import Optional from uuid import UUID from orchestrator import workflow @@ -61,9 +60,9 @@ def initial_input_form_generator() -> FormGenerator: router_lo_ipv4_address: ipaddress.IPv4Address router_lo_ipv6_address: ipaddress.IPv6Address router_lo_iso_address: str - router_si_ipv4_network: Optional[ipaddress.IPv4Network] = None - router_ias_lt_ipv4_network: Optional[ipaddress.IPv4Network] = None - router_ias_lt_ipv6_network: Optional[ipaddress.IPv6Network] = None + router_si_ipv4_network: ipaddress.IPv4Network | None = None + router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None + router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None user_input = yield ImportRouter @@ -78,13 +77,13 @@ def initialize_subscription( router_vendor: router_pb.RouterVendor, router_site: str, router_role: router_pb.RouterRole, - is_ias_connected: Optional[bool] = None, - router_lo_ipv4_address: Optional[ipaddress.IPv4Address] = None, - router_lo_ipv6_address: Optional[ipaddress.IPv6Address] = None, - router_lo_iso_address: Optional[str] = None, - router_si_ipv4_network: Optional[ipaddress.IPv4Network] = None, - router_ias_lt_ipv4_network: Optional[ipaddress.IPv4Network] = None, - router_ias_lt_ipv6_network: Optional[ipaddress.IPv6Network] = None, + is_ias_connected: bool | None = None, + router_lo_ipv4_address: ipaddress.IPv4Address | None = None, + router_lo_ipv6_address: ipaddress.IPv6Address | None = None, + router_lo_iso_address: str | None = None, + router_si_ipv4_network: ipaddress.IPv4Network | None = None, + router_ias_lt_ipv4_network: ipaddress.IPv4Network | None = None, + router_ias_lt_ipv6_network: ipaddress.IPv6Network | None = None, ) -> State: subscription.router.router_ts_port = ts_port subscription.router.router_vendor = router_vendor diff --git a/gso/workflows/utils.py b/gso/workflows/utils.py index 02ebed3eaf5a12f55027063b30728ac4a8336ca1..cd461b81131917b704fcc6b7875f8a2ed7264470 100644 --- a/gso/workflows/utils.py +++ b/gso/workflows/utils.py @@ -1,9 +1,14 @@ import re from ipaddress import IPv4Address +from uuid import UUID from orchestrator.forms.validators import Choice +from orchestrator.types import UUIDstr +from gso.products.product_blocks.router import RouterVendor +from gso.products.product_types.router import Router from gso.services.crm import all_customers +from gso.services.netbox_client import NetboxClient def customer_selector() -> Choice: @@ -14,13 +19,74 @@ def customer_selector() -> Choice: return Choice("Select a customer", zip(customers.keys(), customers.items())) # type: ignore[arg-type] +def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: + """Return a list of available interfaces for a given router and speed. + + For Nokia routers, return a list of available interfaces. + For Juniper routers, return a string. + """ + if Router.from_subscription(router_id).router.router_vendor != RouterVendor.NOKIA: + return None + interfaces = { + interface["name"]: f"{interface['name']} - {interface['module']['display']} - {interface['description']}" + for interface in NetboxClient().get_available_interfaces(router_id, speed) + } + return Choice("ae member", zip(interfaces.keys(), interfaces.items())) # type: ignore[arg-type] + + +def available_lags_choices(router_id: UUID) -> Choice | None: + """Return a list of available lags for a given router. + + For Nokia routers, return a list of available lags. + For Juniper routers, return a string. + """ + + if Router.from_subscription(router_id).router.router_vendor != RouterVendor.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)) # type: ignore[arg-type] + + +def get_router_vendor(router_id: UUID) -> str: + """Retrieve the vendor of a router. + + Args: + ---- + router_id (UUID): The {term}`UUID` of the router. + + Returns: + ------- + str: The vendor of the router. + """ + return Router.from_subscription(router_id).router.router_vendor + + def iso_from_ipv4(ipv4_address: IPv4Address) -> str: - """Calculate an {term}`ISO` address, based on an IPv4 address. + """Calculate an :term:`ISO` address, based on an IPv4 address. :param IPv4Address ipv4_address: The address that's to be converted - :returns: An {term}`ISO`-formatted address. + :returns: An :term:`ISO`-formatted address. """ padded_octets = [f"{x:>03}" for x in str(ipv4_address).split(".")] joined_octets = "".join(padded_octets) re_split = ".".join(re.findall("....", joined_octets)) return ".".join(["49.51e5.0001", re_split, "00"]) + + +def validate_router_in_netbox(subscription_id: UUIDstr) -> UUIDstr | None: + """Verify if a device exists in Netbox. + + Args: + ---- + subscription_id (UUID): The {term}`UUID` of the router subscription. + + Returns: + ------- + UUID: The {term}`UUID` of the router subscription or raises an error. + """ + router = Router.from_subscription(subscription_id).router + if router.router_vendor == RouterVendor.NOKIA: + device = NetboxClient().get_device_by_name(router.router_fqdn) + if not device: + raise ValueError("The selected router does not exist in Netbox.") + return subscription_id diff --git a/requirements.txt b/requirements.txt index b103da38a717e01e8c339a38c8e5534d778183e6..8509293b655fcf5b739c9a92b75fef43eaf9ce0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,15 +2,17 @@ orchestrator-core==1.3.0 requests==2.31.0 infoblox-client~=0.6.0 pycountry==22.3.5 +pynetbox==7.2.0 + +# Test and linting dependencies pytest==7.4.2 -faker==19.6.2 +faker==19.10.0 responses==0.23.3 black==23.9.1 isort==5.12.0 flake8==6.1.0 -mypy==1.5.1 -ruff==0.0.290 +mypy==1.6.0 +ruff==0.0.292 sphinx==7.2.6 sphinx-rtd-theme==1.3.0 -urllib3_mock==0.3.3 -pynetbox==7.2.0 +urllib3_mock==0.3.3 \ No newline at end of file diff --git a/test/test_resource_manager.py b/test/test_resource_manager.py deleted file mode 100644 index 34cf53506c94d37bc36cfbae31d6998cf88a7a30..0000000000000000000000000000000000000000 --- a/test/test_resource_manager.py +++ /dev/null @@ -1,51 +0,0 @@ -import random -import string - -from gso.services import resource_manager - - -def _random_string(n=None, letters=string.ascii_letters + string.digits + string.punctuation): - # ignoring S311 because this is a test - if not n: - n = random.randint(1, 20) # noqa: S311 - return "".join(random.choices(letters, k=n)) # noqa: S311 - - -def test_new_router(): - router_name = _random_string(10) - assert router_name not in resource_manager._DUMMY_INVENTORY - resource_manager.import_new_router(new_router_fqdn=router_name, subscription_id=_random_string(10)) - assert router_name in resource_manager._DUMMY_INVENTORY - - -def test_new_lag(): - router_name = list(resource_manager._DUMMY_INVENTORY.keys())[0] - new_lags = { - resource_manager.next_lag(router_fqdn=router_name, subscription_id=_random_string(10)) for _ in range(10) - } - assert len(new_lags) == 10 - assert new_lags <= set(resource_manager._DUMMY_INVENTORY[router_name]["lags"]) - - -def test_physical_allocation_lifecycle_happy(): - router_name = list(resource_manager._DUMMY_INVENTORY.keys())[0] - - def _interfaces(): - return resource_manager.available_physical_interfaces(router_fqdn=router_name) - - initial_available = _interfaces() - - interface_name = initial_available[0] - - resource_manager.reserve_physical_interface( - router_fqdn=router_name, subscription_id=_random_string(10), interface_name=interface_name - ) - - current_available = _interfaces() - assert interface_name not in current_available - - resource_manager.allocate_physical_interface(router_fqdn=router_name, interface_name=interface_name) - resource_manager.free_physical_interface(router_fqdn=router_name, interface_name=interface_name) - - current_available = _interfaces() - assert interface_name in current_available diff --git a/test/workflows/__init__.py b/test/workflows/__init__.py index 2b42a33928c6c9932b3512bd64b66d56267e9d28..19707ff0ff1b4e53e3309f9c3509c8e24001a9fc 100644 --- a/test/workflows/__init__.py +++ b/test/workflows/__init__.py @@ -2,7 +2,7 @@ import difflib import pprint from copy import deepcopy from itertools import chain, repeat -from typing import Callable, Dict, List, Optional, Tuple, Union, cast +from typing import Callable, cast from uuid import uuid4 import structlog @@ -64,7 +64,7 @@ def assert_state(result, expected): assert expected == actual, f"Invalid state. Expected superset of: {expected}, but was: {actual}" -def assert_state_equal(result: ProcessTable, expected: Dict, excluded_keys: Optional[List[str]] = None) -> None: +def assert_state_equal(result: ProcessTable, expected: dict, excluded_keys: list[str] | None = None) -> None: """Test state with certain keys excluded from both actual and expected state.""" if excluded_keys is None: excluded_keys = ["process_id", "workflow_target", "workflow_name"] @@ -143,7 +143,7 @@ class WorkflowInstanceForTests(LazyWorkflowInstance): return f"WorkflowInstanceForTests('{self.workflow}','{self.name}')" -def _store_step(step_log: List[Tuple[Step, WFProcess]]) -> Callable[[ProcessStat, Step, WFProcess], WFProcess]: +def _store_step(step_log: list[tuple[Step, WFProcess]]) -> Callable[[ProcessStat, Step, WFProcess], WFProcess]: def __store_step(pstat: ProcessStat, step: Step, state: WFProcess) -> WFProcess: try: state = state.map(lambda s: json_loads(json_dumps(s))) @@ -155,23 +155,23 @@ def _store_step(step_log: List[Tuple[Step, WFProcess]]) -> Callable[[ProcessStat return __store_step -def _sanitize_input(input_data: Union[State, List[State]]) -> List[State]: +def _sanitize_input(input_data: State | list[State]) -> list[State]: # To be backwards compatible convert single dict to list - if not isinstance(input_data, List): + if not isinstance(input_data, list): input_data = [input_data] # We need a copy here and we want to mimic the actual code that returns a serialized version of the state - return cast(List[State], json_loads(json_dumps(input_data))) + return cast(list[State], json_loads(json_dumps(input_data))) -def run_workflow(workflow_key: str, input_data: Union[State, List[State]]) -> Tuple[WFProcess, ProcessStat, List]: +def run_workflow(workflow_key: str, input_data: State | list[State]) -> tuple[WFProcess, ProcessStat, list]: # ATTENTION!! This code needs to be as similar as possible to `server.services.processes.start_process` # The main differences are: we use a different step log function and we don't run in # a sepperate thread user_data = _sanitize_input(input_data) user = "john.doe" - step_log: List[Tuple[Step, WFProcess]] = [] + step_log: list[tuple[Step, WFProcess]] = [] process_id = uuid4() workflow = get_workflow(workflow_key) @@ -201,8 +201,8 @@ def run_workflow(workflow_key: str, input_data: Union[State, List[State]]) -> Tu def resume_workflow( - process: ProcessStat, step_log: List[Tuple[Step, WFProcess]], input_data: State -) -> Tuple[WFProcess, List]: + process: ProcessStat, step_log: list[tuple[Step, WFProcess]], input_data: State +) -> tuple[WFProcess, list]: # ATTENTION!! This code needs to be as similar as possible to `server.services.processes.resume_process` # The main differences are: we use a different step log function, and we don't run in a separate thread user_data = _sanitize_input(input_data) @@ -228,8 +228,8 @@ def resume_workflow( def run_form_generator( - form_generator: FormGenerator, extra_inputs: Optional[List[State]] = None -) -> Tuple[List[dict], State]: + form_generator: FormGenerator, extra_inputs: list[State] | None = None +) -> tuple[list[dict], State]: """Run a form generator to get the resulting forms and result. Warning! This does not run the actual pydantic validation on purpose. However, you should @@ -239,13 +239,13 @@ def run_form_generator( Args: ---- form_generator (FormGenerator): The form generator that will be run. - extra_inputs (Optional[List[State]]): List of user input dicts for each page in the generator. + extra_inputs (list[State] | None): list of user input dicts for each page in the generator. If no input is given for a page, an empty dict is used. The default value from the form is used as the default value for a field. Returns: ------- - Tuple[List[dict], State]: A list of generated forms and the result state for the whole generator. + tuple[list[dict], State]: A list of generated forms and the result state for the whole generator. Example: ------- @@ -276,7 +276,7 @@ def run_form_generator( {'field': 'baz', 'bar': 42} """ - forms: List[dict] = [] + forms: list[dict] = [] result: State = {"s": 3} if extra_inputs is None: extra_inputs = [] diff --git a/test/workflows/iptrunks/iptrunks/test_create_iptrunks.py b/test/workflows/iptrunks/iptrunks/test_create_iptrunks.py index dfb6fa4f98e7ebf0ec7afd1b5e09855d09add6f8..cf50c2ccbf1d342816372913be351dfe73fa3c82 100644 --- a/test/workflows/iptrunks/iptrunks/test_create_iptrunks.py +++ b/test/workflows/iptrunks/iptrunks/test_create_iptrunks.py @@ -1,3 +1,4 @@ +from os import PathLike from unittest.mock import patch import pytest @@ -19,6 +20,65 @@ from test.workflows import ( ) +class MockedNetboxClient: + class BaseMockObject: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def get_device_by_name(self): + return self.BaseMockObject(id=1, name="test") + + def get_available_lags(self) -> list[str]: + return [f"LAG{lag}" for lag in range(1, 5)] + + def get_available_interfaces(self): + interfaces = [] + for interface in range(1, 5): + interface_data = { + "name": f"Interface{interface}", + "module": {"display": f"Module{interface}"}, + "description": f"Description{interface}", + } + interfaces.append(interface_data) + return interfaces + + def create_interface(self): + return self.BaseMockObject(id=1, name="test") + + def attach_interface_to_lag(self): + return self.BaseMockObject(id=1, name="test") + + def reserve_interface(self): + return self.BaseMockObject(id=1, name="test") + + def allocate_interface(self): + return {"id": 1, "name": "test"} + + +@pytest.fixture +def netbox_client_mock(): + # Mock NetboxClient methods + with ( + patch("gso.services.netbox_client.NetboxClient.get_device_by_name") as mock_get_device_by_name, + patch("gso.services.netbox_client.NetboxClient.get_available_interfaces") as mock_get_available_interfaces, + patch("gso.services.netbox_client.NetboxClient.get_available_lags") as mock_get_available_lags, + patch("gso.services.netbox_client.NetboxClient.create_interface") as mock_create_interface, + patch("gso.services.netbox_client.NetboxClient.attach_interface_to_lag") as mock_attach_interface_to_lag, + patch("gso.services.netbox_client.NetboxClient.reserve_interface") as mock_reserve_interface, + patch("gso.services.netbox_client.NetboxClient.allocate_interface") as mock_allocate_interface, + ): + mock_get_device_by_name.return_value = MockedNetboxClient().get_device_by_name() + mock_get_available_interfaces.return_value = MockedNetboxClient().get_available_interfaces() + mock_get_available_lags.return_value = MockedNetboxClient().get_available_lags() + mock_create_interface.return_value = MockedNetboxClient().create_interface() + mock_attach_interface_to_lag.return_value = MockedNetboxClient().attach_interface_to_lag() + mock_reserve_interface.return_value = MockedNetboxClient().reserve_interface() + mock_allocate_interface.return_value = MockedNetboxClient().allocate_interface() + + yield + + @pytest.fixture def input_form_wizard_data(router_subscription_factory, faker): router_side_a = router_subscription_factory() @@ -31,25 +91,31 @@ def input_form_wizard_data(router_subscription_factory, faker): "iptrunk_type": IptrunkType.DARK_FIBER, "iptrunk_description": faker.sentence(), "iptrunk_speed": PhyPortCapacity.HUNDRED_GIGABIT_PER_SECOND, - "iptrunk_minimum_links": 5, + "iptrunk_minimum_links": 2, } + create_ip_trunk_side_a_router_name = {"iptrunk_sideA_node_id": router_side_a} create_ip_trunk_side_a_step = { - "iptrunk_sideA_node_id": router_side_a, - "iptrunk_sideA_ae_iface": faker.pystr(), + "iptrunk_sideA_ae_iface": "LAG1", "iptrunk_sideA_ae_geant_a_sid": faker.pystr(), - "iptrunk_sideA_ae_members": [faker.pystr() for _ in range(5)], - "iptrunk_sideA_ae_members_descriptions": [faker.sentence() for _ in range(5)], + "iptrunk_sideA_ae_members": ["Interface1", "Interface2"], + "iptrunk_sideA_ae_members_descriptions": ["Interface1 Description", "Interface2 Description"], } + create_ip_trunk_side_b_router_name = {"iptrunk_sideB_node_id": router_side_b} create_ip_trunk_side_b_step = { - "iptrunk_sideB_node_id": router_side_b, - "iptrunk_sideB_ae_iface": faker.pystr(), + "iptrunk_sideB_ae_iface": "LAG1", "iptrunk_sideB_ae_geant_a_sid": faker.pystr(), - "iptrunk_sideB_ae_members": [faker.pystr() for _ in range(5)], - "iptrunk_sideB_ae_members_descriptions": [faker.sentence() for _ in range(5)], + "iptrunk_sideB_ae_members": ["Interface1", "Interface2"], + "iptrunk_sideB_ae_members_descriptions": ["Interface1 Description", "Interface2 Description"], } - return [create_ip_trunk_step, create_ip_trunk_side_a_step, create_ip_trunk_side_b_step] + return [ + create_ip_trunk_step, + create_ip_trunk_side_a_router_name, + create_ip_trunk_side_a_step, + create_ip_trunk_side_b_router_name, + create_ip_trunk_side_b_step, + ] def _user_accept_and_assert_suspended(process_stat, step_log, extra_data=None): @@ -73,6 +139,8 @@ def test_successful_iptrunk_creation_with_standard_lso_result( responses, input_form_wizard_data, faker, + data_config_filename: PathLike, + netbox_client_mock, ): mock_allocate_v4_network.return_value = faker.ipv4_network() mock_allocate_v6_network.return_value = faker.ipv6_network() @@ -92,10 +160,10 @@ def test_successful_iptrunk_creation_with_standard_lso_result( } for _ in range(5): result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log) + result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) - result, step_log = resume_workflow(process_stat, step_log, {}) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) assert_complete(result) state = extract_state(result) @@ -122,6 +190,8 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( responses, input_form_wizard_data, faker, + netbox_client_mock, + data_config_filename: PathLike, ): mock_allocate_v4_network.return_value = faker.ipv4_network() mock_allocate_v6_network.return_value = faker.ipv6_network() @@ -144,10 +214,10 @@ def test_iptrunk_creation_fails_when_lso_return_code_is_one( attempts = 3 for _ in range(0, attempts - 1): result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) - result, step_log = _user_accept_and_assert_suspended(process_stat, step_log) + result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, [{}, {}]) result, step_log = _user_accept_and_assert_suspended(process_stat, step_log, standard_lso_result) - result, step_log = resume_workflow(process_stat, step_log, {}) + result, step_log = resume_workflow(process_stat, step_log, [{}, {}]) assert_aborted(result) assert mock_provision_ip_trunk.call_count == attempts diff --git a/utils/netboxcli.py b/utils/netboxcli.py index b934f5e7c94b1f43d3692865041bdd02d2af95bf..1d46bbb3db64520017c1bcedd07545b48d099166 100644 --- a/utils/netboxcli.py +++ b/utils/netboxcli.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List import click import pandas as pd -from gso.services.netbox_client import NetBoxClient +from gso.services.netbox_client import NetboxClient def convert_to_table(data: List[Dict[str, Any]], fields: List[str]) -> pd.DataFrame: @@ -33,7 +33,7 @@ def create() -> None: @click.option("--model", default="vmx", help="Device model") def device(fqdn: str, model: str) -> None: click.echo(f"Creating device: fqdn={fqdn}, model={model}") - new_device = NetBoxClient().create_device(fqdn, model) + new_device = NetboxClient().create_device(fqdn, model) click.echo(new_device) @@ -44,7 +44,7 @@ def device(fqdn: str, model: str) -> None: @click.option("--fqdn", help="Device where to create interface") def interface(name: str, type: str, speed: str, fqdn: str) -> None: click.echo(f"Creating interface: name={name}, speed={speed}, fqdn={fqdn}") - new_interface = NetBoxClient().create_interface(name, type, speed, fqdn) + new_interface = NetboxClient().create_interface(name, type, speed, fqdn) click.echo(new_interface) @@ -53,7 +53,7 @@ def interface(name: str, type: str, speed: str, fqdn: str) -> None: @click.option("--slug", help="Short name for manufacturer") def manufacturer(name: str, slug: str) -> None: click.echo(f"Creating manufacturer: name={name}") - manufacturer = NetBoxClient().create_device_manufacturer(name, slug) + manufacturer = NetboxClient().create_device_manufacturer(name, slug) click.echo(manufacturer) @@ -63,7 +63,7 @@ def manufacturer(name: str, slug: str) -> None: @click.option("--slug", help="Short name for manufacturer") def device_type(manufacturer: str, model: str, slug: str) -> None: click.echo(f"Creating device type: manufacturer={manufacturer} model = {model}") - device_type = NetBoxClient().create_device_type(manufacturer, model, slug) + device_type = NetboxClient().create_device_type(manufacturer, model, slug) click.echo(device_type) @@ -72,7 +72,7 @@ def device_type(manufacturer: str, model: str, slug: str) -> None: @click.option("--slug", help="Short name for device role") def device_role(name: str, slug: str) -> None: click.echo(f"Creating device role: name={name}") - device_role = NetBoxClient().create_device_role(name, slug) + device_role = NetboxClient().create_device_role(name, slug) click.echo(device_role) @@ -81,7 +81,7 @@ def device_role(name: str, slug: str) -> None: @click.option("--slug", help="Short name for device site") def device_site(name: str, slug: str) -> None: click.echo(f"Creating device site: name={name}") - device_site = NetBoxClient().create_device_site(name, slug) + device_site = NetboxClient().create_device_site(name, slug) click.echo(device_site) @@ -104,7 +104,7 @@ def list() -> None: @click.option("--speed", default="1000", help="Interface speed to list interfaces (default 1000=1G)") def interfaces(fqdn: str, speed: str) -> None: click.echo(f"Listing all interfaces for: device with fqdn={fqdn}, speed={speed}") - interface_list = NetBoxClient().get_interfaces_by_device(fqdn, speed) + interface_list = NetboxClient().get_interfaces_by_device(fqdn, speed) display_fields = ["name", "enabled", "mark_connected", "custom_fields", "lag", "speed"] iface_list = [] for iface in interface_list: @@ -117,7 +117,7 @@ def interfaces(fqdn: str, speed: str) -> None: @list.command() def devices() -> None: click.echo("Listing all devices:") - device_list = NetBoxClient().get_all_devices() + device_list = NetboxClient().get_all_devices() display_fields = ["name", "device_type"] devices = [] for device in device_list: @@ -143,7 +143,7 @@ def attach() -> None: @click.option("--lag", help="LAG name to attach interface") def interface_to_lag(fqdn: str, iface: str, lag: str) -> None: click.echo(f"Attaching interface to lag: device ={fqdn}, interface name={iface} to lag={lag}") - new_iface = NetBoxClient().attach_interface_to_lag(fqdn, lag, iface) + new_iface = NetboxClient().attach_interface_to_lag(fqdn, lag, iface) click.echo(new_iface) @@ -161,7 +161,7 @@ def reserve() -> None: @click.option("--iface", help="Interface name to reserve") def reserve_interface(fqdn: str, iface: str) -> None: click.echo(f"Reserving interface: device ={fqdn}, interface name={iface}") - reserved_iface = NetBoxClient().reserve_interface(fqdn, iface) + reserved_iface = NetboxClient().reserve_interface(fqdn, iface) click.echo(reserved_iface)