diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70bfc27e17dce4bdd2df10ee86b2490ec7789d98..45164592a74d3f5e59204397932331edde5141a3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,9 @@ repos: - --fix - --preview - --ignore=PLR0917,PLR0914 + - --extend-exclude=test/* # Run the formatter. - id: ruff-format args: - --preview + - --exclude=test/* diff --git a/Changelog.md b/Changelog.md index e5624c2a65a0a385f73ba3774b9734479c6db862..d87116de079b184e123062fd106f19bcd2abc6c9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,8 @@ # Changelog All notable changes to this project will be documented in this file. +## [0.7] - 2024-02-21 +- Infoblox client: added support for the `network_view` (IPAM). ## [0.6] - 2024-02-07 - Removed the import workflows from migrations. diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 959d89761d27f3652cbae2d840a5e2ef2f7a3703..0bbcce276f79bee067aef114ea5b0f0c3101ab39 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -58,6 +58,9 @@ Glossary of terms Simple Network Management Protocol: a protocol that's used for gathering data, widely used for network management and monitoring. + TWAMP + Two-way Active Measurement Protocol. + UUID Universally Unique Identifier diff --git a/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt b/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt index 081f7913cba3d95518c76bfb75278bdf9300dbf8..9172c42e39a10632a75a6e73c17dd9b0e9b5eb41 100644 --- a/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt +++ b/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt @@ -14,4 +14,6 @@ Dark_fiber PHASE 1 [Mm]odify AAI -[M|m]iddleware \ No newline at end of file +[M|m]iddleware +TWAMP +Pydantic diff --git a/gso/auth/oidc_policy_helper.py b/gso/auth/oidc_policy_helper.py index 04e2fc8e5ec419fb5f80a6a5646bb3429512cad9..241641dcfe3a93ffd41bb2962255c6557b95d235 100644 --- a/gso/auth/oidc_policy_helper.py +++ b/gso/auth/oidc_policy_helper.py @@ -82,13 +82,8 @@ class OIDCUserModel(dict): if the attribute is one of the registered claims or raises an AttributeError if the key is not found. - Args: - ---- - key: The attribute name to retrieve. - - Returns: - ------- - The value of the attribute if it exists, otherwise raises AttributeError. + :param str key: The attribute name to retrieve. + :return: The value of the attribute if it exists, otherwise raises AttributeError. """ try: return object.__getattribute__(self, key) @@ -167,6 +162,7 @@ class OPAResult(BaseModel): ---------- - result (bool): Indicates whether the access request is allowed or denied. - decision_id (str): A unique identifier for the decision made by OPA. + """ result: bool = False @@ -208,15 +204,10 @@ class OIDCUser(HTTPBearer): This is used as a security module in Fastapi projects - Args: - ---- - request: Starlette request method. - token: Optional value to directly pass a token. - - Returns: - ------- - OIDCUserModel object. + :param Request request: Starlette request method. + :param str token: Optional value to directly pass a token. + :return: OIDCUserModel object. """ if not oauth2lib_settings.OAUTH2_ACTIVE: return None @@ -380,16 +371,12 @@ def opa_decision( to authorize requests based on OPA policies. It utilizes OIDC for user information and makes a call to the OPA service to determine authorization. - Args: - ---- - opa_url: URL of the Open Policy Agent service. - oidc_security: An instance of OIDCUser for user authentication. - auto_error: If True, automatically raises an HTTPException on authorization failure. - opa_kwargs: Additional keyword arguments to be passed to the OPA input. + :param str opa_url: URL of the Open Policy Agent service. + :param OIDCUser oidc_security: An instance of OIDCUser for user authentication. + :param bool auto_error: If True, automatically raises an HTTPException on authorization failure. + :param Mapping[str, str] | None opa_kwargs: Additional keyword arguments to be passed to the OPA input. - Returns: - ------- - An asynchronous decision function that can be used as a dependency in FastAPI endpoints. + :return: An asynchronous decision function that can be used as a dependency in FastAPI endpoints. """ async def _opa_decision( @@ -407,6 +394,7 @@ def opa_decision( request: Request object that will be used to retrieve request metadata. user_info: The OIDCUserModel object that will be checked async_request: The :term:`httpx` client. + """ if not (oauth2lib_settings.OAUTH2_ACTIVE and oauth2lib_settings.OAUTH2_AUTHORIZATION_ACTIVE): return None diff --git a/gso/auth/security.py b/gso/auth/security.py index 16065e467e02176d92df20563c4c3e0f56845667..e1d5376479e9e95f50847af06fa7120272135a4a 100644 --- a/gso/auth/security.py +++ b/gso/auth/security.py @@ -34,8 +34,6 @@ def get_oidc_user() -> OIDCUser: This function returns the instance of OIDCUser initialized in the module. It is typically used for accessing the OIDCUser across different parts of the application. - Returns - ------- - OIDCUser: The instance of OIDCUser configured with OAuth2 settings. + :return OIDCUser: The instance of OIDCUser configured with OAuth2 settings. """ return oidc_user diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json index 931367ea48746610b29646651e99a68f89d1288e..2ef1cd14749760e423c59676896c32638286989b 100644 --- a/gso/oss-params-example.json +++ b/gso/oss-params-example.json @@ -18,31 +18,36 @@ "V4": {"containers": [], "networks": ["1.1.0.0/24"], "mask": 0}, "V6": {"containers": [], "networks": ["dead:beef::/64"], "mask": 0}, "domain_name": ".lo", - "dns_view": "default" + "dns_view": "default", + "network_view": "default" }, "TRUNK": { "V4": {"containers": ["1.1.1.0/24"], "networks": [], "mask": 31}, "V6": {"containers": ["dead:beef::/64"], "networks": [], "mask": 126}, "domain_name": ".trunk", - "dns_view": "default" + "dns_view": "default", + "network_view": "default" }, "GEANT_IP": { "V4": {"containers": ["1.1.2.0/24"], "networks": [], "mask": 31}, "V6": {"containers": ["dead:beef::/64"], "networks": [], "mask": 126}, "domain_name": ".geantip", - "dns_view": "default" + "dns_view": "default", + "network_view": "default" }, "SI": { "V4": {"containers": ["1.1.3.0/24"], "networks": [], "mask": 31}, "V6": {"containers": ["dead:beef::/64"], "networks": [], "mask": 126}, "domain_name": ".si", - "dns_view": "default" + "dns_view": "default", + "network_view": "default" }, "LT_IAS": { "V4": {"containers": ["1.1.4.0/24"], "networks": [], "mask": 31}, "V6": {"containers": ["dead:beef::/64"], "networks": [], "mask": 126}, "domain_name": ".ltias", - "dns_view": "default" + "dns_view": "default", + "network_view": "default" } }, "MONITORING": { @@ -77,5 +82,13 @@ "THIRD_PARTY_API_KEYS": { "AnsibleDynamicInventoryGenerator": "REALLY_random_AND_secure_T0keN", "Application_2": "another_REALY_random_AND_3cure_T0keN" + }, + "EMAIL": { + "from_address": "noreply@nren.local", + "smtp_host": "smtp.nren.local", + "smtp_port": 487, + "starttls_enabled": true, + "smtp_username": "username", + "smtp_password": "password" } } diff --git a/gso/products/product_blocks/router.py b/gso/products/product_blocks/router.py index d8e9996ba0821d48765ae36f50df1bb2aa541752..5d1a307d85825759b734a0c06b665d1e7eb06866 100644 --- a/gso/products/product_blocks/router.py +++ b/gso/products/product_blocks/router.py @@ -68,12 +68,12 @@ class RouterBlockProvisioning(RouterBlockInactive, lifecycle=[SubscriptionLifecy router_fqdn: str router_ts_port: PortNumber - 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_role: RouterRole | None = None - router_site: SiteBlockProvisioning | None + router_access_via_ts: bool + router_lo_ipv4_address: ipaddress.IPv4Address + router_lo_ipv6_address: ipaddress.IPv6Address + router_lo_iso_address: str + router_role: RouterRole + router_site: SiteBlockProvisioning vendor: RouterVendor diff --git a/gso/products/product_blocks/site.py b/gso/products/product_blocks/site.py index efe21c4b270a8974c1615b89f025012ff8793582..1852b24615076b2d76dde41db71a9e5d5fcc535f 100644 --- a/gso/products/product_blocks/site.py +++ b/gso/products/product_blocks/site.py @@ -82,16 +82,16 @@ class SiteBlockInactive( class SiteBlockProvisioning(SiteBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): """A site that's currently being provisioned, see :class:`SiteBlock`.""" - 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 + site_name: str + site_city: str + site_country: str + site_country_code: str + site_latitude: LatitudeCoordinate + site_longitude: LongitudeCoordinate + site_internal_id: int + site_bgp_community_id: int + site_tier: SiteTier + site_ts_address: str class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): @@ -120,4 +120,4 @@ class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]) #: 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. - site_ts_address: str | None = None + site_ts_address: str diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index 0c7176deb5dd41b6a1c6d7cf5dcea30a2992a4ba..6838ad9eada39ad5fe3999f0f28096e04216388a 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -41,29 +41,32 @@ def _setup_connection() -> tuple[connector.Connector, IPAMParams]: def _allocate_network( - conn: connector.Connector, dns_view: str, netmask: int, containers: list[str], comment: str | None = "" + conn: connector.Connector, + dns_view: str, + network_view: str, + netmask: int, + containers: list[str], + comment: str | None = "", ) -> ipaddress.IPv4Network | ipaddress.IPv6Network: """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`. - :param conn: An active Infoblox connection. - :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` - notation. - :type containers: list[str] - :param comment: Optionally, a comment can be added to the network allocation. - :type comment: str, optional + :param :class:`infoblox_client.connector.Connector` conn: An active Infoblox connection. + :param str dns_view: The Infoblox ``dns_view`` in which the network should be allocated. + :param str network_view: The Infoblox ``network_view`` where the network should be allocated. + :param int netmask: The netmask of the desired network. Can be up to 32 for v4 networks, and 128 for v6 networks. + :param list [str] containers: A list of network containers in which the network should be allocated, given in + :term:`CIDR` notation. + :param str comment: Optionally, a comment can be added to the network allocation. """ for container in [ipaddress.ip_network(con) for con in containers]: for network in container.subnets(new_prefix=netmask): if objects.Network.search(conn, network=str(network)) is None: - created_net = objects.Network.create(conn, network=str(network), dns_view=dns_view, comment=comment) + created_net = objects.Network.create( + conn, network=str(network), dns_view=dns_view, network_view=network_view, comment=comment + ) if created_net.response != "Infoblox Object already Exists": return ipaddress.ip_network(created_net.network) msg = f"IP container {container} appears to be full." @@ -104,8 +107,9 @@ def allocate_v4_network(service_type: str, comment: str | None = "") -> ipaddres netmask = getattr(oss, service_type).V4.mask containers = getattr(oss, service_type).V4.containers dns_view = getattr(oss, service_type).dns_view + network_view = getattr(oss, service_type).network_view - return ipaddress.IPv4Network(_allocate_network(conn, dns_view, netmask, containers, comment)) + return ipaddress.IPv4Network(_allocate_network(conn, dns_view, network_view, netmask, containers, comment)) def allocate_v6_network(service_type: str, comment: str | None = "") -> ipaddress.IPv6Network: @@ -123,8 +127,9 @@ def allocate_v6_network(service_type: str, comment: str | None = "") -> ipaddres netmask = getattr(oss, service_type).V6.mask containers = getattr(oss, service_type).V6.containers dns_view = getattr(oss, service_type).dns_view + network_view = getattr(oss, service_type).network_view - return ipaddress.IPv6Network(_allocate_network(conn, dns_view, netmask, containers, comment)) + return ipaddress.IPv6Network(_allocate_network(conn, dns_view, network_view, netmask, containers, comment)) def find_network_by_cidr( @@ -184,10 +189,11 @@ def allocate_host( allocation_networks_v4 = getattr(oss, service_type).V4.networks allocation_networks_v6 = getattr(oss, service_type).V6.networks dns_view = getattr(oss, service_type).dns_view + network_view = getattr(oss, service_type).network_view created_v6 = None for ipv6_range in allocation_networks_v6: - v6_alloc = objects.IPAllocation.next_available_ip_from_cidr(dns_view, str(ipv6_range)) + v6_alloc = objects.IPAllocation.next_available_ip_from_cidr(network_view, str(ipv6_range)) ipv6_object = objects.IP.create(ip=v6_alloc, mac=NULL_MAC, configure_for_dhcp=False) try: new_host = objects.HostRecord.create( @@ -197,6 +203,7 @@ def allocate_host( aliases=cname_aliases, comment=comment, dns_view=dns_view, + network_view=network_view, ) created_v6 = ipaddress.IPv6Address(new_host.ipv6addr) except InfobloxCannotCreateObject: @@ -209,7 +216,7 @@ def allocate_host( created_v4 = None for ipv4_range in allocation_networks_v4: - v4_alloc = objects.IPAllocation.next_available_ip_from_cidr(dns_view, str(ipv4_range)) + v4_alloc = objects.IPAllocation.next_available_ip_from_cidr(network_view, str(ipv4_range)) ipv4_object = objects.IP.create(ip=v4_alloc, mac=NULL_MAC, configure_for_dhcp=False) new_host = objects.HostRecord.search(conn, name=hostname) new_host.ipv4addrs = [ipv4_object] @@ -240,7 +247,8 @@ def create_host_by_ip( :param str hostname: The :term:`FQDN` of the new host. :param IPv4Address ipv4_address: The IPv4 address of the new host. :param IPv6Address ipv6_address: The IPv6 address of the new host. - :param str service_type: The relevant service type, used to deduce the correct ``dns_view`` in Infoblox. + :param str service_type: The relevant service type, used to deduce the correct ``dns_view`` and ``network_view`` in + Infoblox. :param str comment: The comment stored in this Infoblox record, most likely the relevant ``subscription_id`` in :term:`GSO`. """ @@ -252,9 +260,12 @@ def create_host_by_ip( ipv6_object = objects.IP.create(ip=ipv6_address, mac=NULL_MAC, configure_for_dhcp=False) ipv4_object = objects.IP.create(ip=ipv4_address, mac=NULL_MAC, configure_for_dhcp=False) dns_view = getattr(oss, service_type).dns_view + network_view = getattr(oss, service_type).network_view # This needs to be done in two steps, otherwise only one of the IP addresses is stored. - objects.HostRecord.create(conn, ip=ipv6_object, name=hostname, comment=comment, dns_view=dns_view) + objects.HostRecord.create( + conn, ip=ipv6_object, name=hostname, comment=comment, dns_view=dns_view, network_view=network_view + ) new_host = find_host_by_fqdn(hostname) new_host.ipv4addrs = [ipv4_object] new_host.update() diff --git a/gso/services/mailer.py b/gso/services/mailer.py new file mode 100644 index 0000000000000000000000000000000000000000..3cd85b7369fd176d55d02674f9b55e2c02a47a72 --- /dev/null +++ b/gso/services/mailer.py @@ -0,0 +1,32 @@ +"""The mailer service sends notification emails, as part of workflows that require interaction with external parties.""" + +import smtplib +from email.message import EmailMessage +from ssl import create_default_context + +from gso.settings import load_oss_params + + +def send_mail(recipient: str, subject: str, body: str) -> None: + """Send an email message to the given address. + + Only supports STARTTLS, not SSL. + + :param str recipient: The destination address. + :param str subject: The email subject. + :param str body: The contents of the email message. + """ + email_params = load_oss_params().EMAIL + msg = EmailMessage() + msg["From"] = email_params.from_address + msg["To"] = recipient + msg["Subject"] = subject + msg.set_content(body) + + with smtplib.SMTP(email_params.smtp_host, email_params.smtp_port) as s: + if email_params.starttls_enabled: + tls_context = create_default_context() + s.starttls(context=tls_context) + if email_params.smtp_username and email_params.smtp_password: + s.login(email_params.smtp_username, email_params.smtp_password) + s.send_message(msg) diff --git a/gso/settings.py b/gso/settings.py index ca0da591c00e829585db55c5a68731b3c44aac90..2055e96c78e9177aad9f2b06b2871666b4eced70 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -81,6 +81,7 @@ class ServiceNetworkParams(BaseSettings): V6: V6NetworkParams domain_name: str dns_view: str + network_view: str class IPAMParams(BaseSettings): @@ -152,6 +153,18 @@ class NetBoxParams(BaseSettings): api: str +class EmailParams(BaseSettings): + """Parameters for the email service.""" + + # TODO: Use more strict types after we've migrated to Pydantic 2.x + from_address: str + smtp_host: str + smtp_port: int + starttls_enabled: bool + smtp_username: str | None + smtp_password: str | None + + class OSSParams(BaseSettings): """The set of parameters required for running :term:`GSO`.""" @@ -162,6 +175,7 @@ class OSSParams(BaseSettings): PROVISIONING_PROXY: ProvisioningProxyParams CELERY: CeleryParams THIRD_PARTY_API_KEYS: dict[str, str] + EMAIL: EmailParams def load_oss_params() -> OSSParams: diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index 689ce2c9e3dd262ec328afb3ed0a8a20abcebf87..c43a80737f56e37e3288e78d89a0ab38dced94e4 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -77,8 +77,7 @@ def available_interfaces_choices_including_current_members( ], ) options = { - interface["name"]: f"{interface['name']} - {interface['module']['display']} - {interface['description']}" - for interface in available_interfaces + interface["name"]: f"{interface['name']} {interface['description']}" for interface in available_interfaces } return Choice("ae member", zip(options.keys(), options.items(), strict=True)) # type: ignore[arg-type] @@ -271,3 +270,23 @@ def validate_interface_name_list(interface_name_list: list, vendor: str) -> list ) raise ValueError(error_msg) return interface_name_list + + +def validate_tt_number(tt_number: str) -> str: + """Validate a string to match a specific pattern. + + This method checks if the input string starts with 'TT#' and is followed by exactly 16 digits. + + :param str tt_number: The TT number as string to validate + + :return str: The TT number string if TT number match was successful, otherwise it will raise a ValueError. + """ + pattern = r"^TT#\d{16}$" + if not bool(re.match(pattern, tt_number)): + err_msg = ( + f"The given TT number: {tt_number} is not valid. " + f" A valid TT number starts with 'TT#' followed by 16 digits." + ) + raise ValueError(err_msg) + + return tt_number diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index acbfe3757568c2257e828e4ddbd506fcaac25bfd..db6f91ce99d706d1c97d59bbda15785532f36d50 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -17,12 +17,12 @@ from pynetbox.models.dcim import Interfaces from gso.products.product_blocks.iptrunk import ( IptrunkInterfaceBlockInactive, - IptrunkSideBlockProvisioning, + IptrunkSideBlockInactive, IptrunkType, PhyPortCapacity, ) from gso.products.product_blocks.router import RouterVendor -from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning +from gso.products.product_types.iptrunk import IptrunkInactive from gso.products.product_types.router import Router from gso.services import infoblox, subscriptions from gso.services.crm import get_customer_by_name @@ -36,6 +36,7 @@ from gso.utils.helpers import ( validate_interface_name_list, validate_iptrunk_unique_interface, validate_router_in_netbox, + validate_tt_number, ) @@ -57,6 +58,10 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: iptrunk_speed: PhyPortCapacity iptrunk_minimum_links: int + @validator("tt_number", allow_reuse=True) + def validate_tt_number(cls, tt_number: str) -> str: + return validate_tt_number(tt_number) + initial_user_input = yield CreateIptrunkForm router_enum_a = Choice("Select a router", zip(routers.keys(), routers.items(), strict=True)) # type: ignore[arg-type] @@ -101,13 +106,11 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: side_a_ae_members: ae_members_side_a # type: ignore[valid-type] @validator("side_a_ae_members", allow_reuse=True) - def validate_iptrunk_unique_interface_side_a(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: - return validate_iptrunk_unique_interface(side_a_ae_members) - - @validator("side_a_ae_members", allow_reuse=True) - def validate_interface_name_members(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: + def validate_side_a_ae_members(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: + validate_iptrunk_unique_interface(side_a_ae_members) vendor = get_router_vendor(router_a) - return validate_interface_name_list(side_a_ae_members, vendor) + validate_interface_name_list(side_a_ae_members, vendor) + return side_a_ae_members user_input_side_a = yield CreateIptrunkSideAForm # Remove the selected router for side A, to prevent any loops @@ -153,13 +156,11 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: side_b_ae_members: ae_members_side_b # type: ignore[valid-type] @validator("side_b_ae_members", allow_reuse=True) - def validate_iptrunk_unique_interface_side_b(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: - return validate_iptrunk_unique_interface(side_b_ae_members) - - @validator("side_b_ae_members", allow_reuse=True) - def validate_interface_name_members(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: + def validate_side_b_ae_members(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: + validate_iptrunk_unique_interface(side_b_ae_members) vendor = get_router_vendor(router_b) - return validate_interface_name_list(side_b_ae_members, vendor) + validate_interface_name_list(side_b_ae_members, vendor) + return side_b_ae_members user_input_side_b = yield CreateIptrunkSideBForm @@ -184,7 +185,7 @@ def create_subscription(product: UUIDstr, customer: str) -> State: @step("Get information from IPAM") -def get_info_from_ipam(subscription: IptrunkProvisioning) -> State: +def get_info_from_ipam(subscription: IptrunkInactive) -> State: """Allocate IP resources in :term:`IPAM`.""" subscription.iptrunk.iptrunk_ipv4_network = infoblox.allocate_v4_network( "TRUNK", @@ -242,14 +243,13 @@ def initialize_subscription( ) side_names = sorted([side_a.router_site.site_name, side_b.router_site.site_name]) subscription.description = f"IP trunk {side_names[0]} {side_names[1]}, geant_s_sid:{geant_s_sid}" - subscription = IptrunkProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) return {"subscription": subscription} @step("Provision IP trunk interface [DRY RUN]") def provision_ip_trunk_iface_dry( - subscription: IptrunkProvisioning, + subscription: IptrunkInactive, callback_route: str, process_id: UUIDstr, tt_number: str, @@ -277,7 +277,7 @@ def provision_ip_trunk_iface_dry( @step("Provision IP trunk interface [FOR REAL]") def provision_ip_trunk_iface_real( - subscription: IptrunkProvisioning, + subscription: IptrunkInactive, callback_route: str, process_id: UUIDstr, tt_number: str, @@ -305,7 +305,7 @@ def provision_ip_trunk_iface_real( @step("Check IP connectivity of the trunk") def check_ip_trunk_connectivity( - subscription: IptrunkProvisioning, + subscription: IptrunkInactive, callback_route: str, ) -> State: """Check successful connectivity across the new trunk.""" @@ -323,7 +323,7 @@ def check_ip_trunk_connectivity( @step("Provision IP trunk ISIS interface [DRY RUN]") def provision_ip_trunk_isis_iface_dry( - subscription: IptrunkProvisioning, + subscription: IptrunkInactive, callback_route: str, process_id: UUIDstr, tt_number: str, @@ -351,7 +351,7 @@ def provision_ip_trunk_isis_iface_dry( @step("Provision IP trunk ISIS interface [FOR REAL]") def provision_ip_trunk_isis_iface_real( - subscription: IptrunkProvisioning, + subscription: IptrunkInactive, callback_route: str, process_id: UUIDstr, tt_number: str, @@ -379,7 +379,7 @@ def provision_ip_trunk_isis_iface_real( @step("Check ISIS adjacency") def check_ip_trunk_isis( - subscription: IptrunkProvisioning, + subscription: IptrunkInactive, callback_route: str, ) -> State: """Run an Ansible playbook to confirm :term:`ISIS` adjacency.""" @@ -396,7 +396,7 @@ def check_ip_trunk_isis( @step("NextBox integration") -def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: +def reserve_interfaces_in_netbox(subscription: IptrunkInactive) -> State: """Create the :term:`LAG` interfaces in NetBox and attach the lag interfaces to the physical interfaces.""" nbclient = NetboxClient() for trunk_side in subscription.iptrunk.iptrunk_sides: @@ -428,22 +428,25 @@ def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State: } -def _allocate_interfaces_in_netbox(iptrunk_side: IptrunkSideBlockProvisioning) -> None: +def _allocate_interfaces_in_netbox(iptrunk_side: IptrunkSideBlockInactive) -> None: for interface in iptrunk_side.iptrunk_side_ae_members: - NetboxClient().allocate_interface( - device_name=iptrunk_side.iptrunk_side_node.router_fqdn, - iface_name=interface.interface_name, - ) + fqdn = iptrunk_side.iptrunk_side_node.router_fqdn + iface_name = interface.interface_name + if not fqdn or not iface_name: + msg = f"FQDN and/or interface name missing in subscription {interface.owner_subscription_id}" + raise ValueError(msg) + + NetboxClient().allocate_interface(device_name=fqdn, iface_name=iface_name) @step("Allocate interfaces in Netbox for side A") -def netbox_allocate_side_a_interfaces(subscription: IptrunkProvisioning) -> None: +def netbox_allocate_side_a_interfaces(subscription: IptrunkInactive) -> None: """Allocate the :term:`LAG` interfaces for the Nokia router on side A.""" _allocate_interfaces_in_netbox(subscription.iptrunk.iptrunk_sides[0]) @step("Allocate interfaces in Netbox for side B") -def netbox_allocate_side_b_interfaces(subscription: IptrunkProvisioning) -> None: +def netbox_allocate_side_b_interfaces(subscription: IptrunkInactive) -> None: """Allocate the :term:`LAG` interfaces for the Nokia router on side B.""" _allocate_interfaces_in_netbox(subscription.iptrunk.iptrunk_sides[1]) @@ -484,7 +487,7 @@ def create_iptrunk() -> StepList: >> pp_interaction(check_ip_trunk_isis) >> side_a_is_nokia(netbox_allocate_side_a_interfaces) >> side_b_is_nokia(netbox_allocate_side_b_interfaces) - >> set_status(SubscriptionLifecycle.ACTIVE) + >> set_status(SubscriptionLifecycle.PROVISIONING) >> resync >> done ) diff --git a/gso/workflows/iptrunk/deploy_twamp.py b/gso/workflows/iptrunk/deploy_twamp.py index c5b74be3909bf073fa38e416555c5c781df73901..8e2e1016b17fbfc6ba5840e8f47a9c831c9ced17 100644 --- a/gso/workflows/iptrunk/deploy_twamp.py +++ b/gso/workflows/iptrunk/deploy_twamp.py @@ -7,9 +7,11 @@ from orchestrator.types import FormGenerator, State, UUIDstr from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form +from pydantic import validator from gso.products.product_types.iptrunk import Iptrunk from gso.services.provisioning_proxy import execute_playbook, pp_interaction +from gso.utils.helpers import validate_tt_number def _initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -23,6 +25,10 @@ def _initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) tt_number: str + @validator("tt_number", allow_reuse=True) + def validate_tt_number(cls, tt_number: str) -> str: + return validate_tt_number(tt_number) + user_input = yield DeployTWAMPForm return user_input.dict() diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index 810041cd89a7a5c7104ddd8a8ab5f368ac5ac068..71729149bf86db868b0bfb477565120ff8e4d98d 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -39,6 +39,7 @@ from gso.utils.helpers import ( available_lags_choices, get_router_vendor, validate_interface_name_list, + validate_tt_number, ) from gso.utils.workflow_steps import set_isis_to_90000 @@ -71,6 +72,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: migrate_to_different_site: bool = False restore_isis_metric: bool = True + @validator("tt_number", allow_reuse=True, pre=True, always=True) + def validate_tt_number(cls, tt_number: str) -> str: + return validate_tt_number(tt_number) + migrate_form_input = yield IPTrunkMigrateForm current_routers = [ diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 8394170f7d983da26d125ba3d0a95a749937d49e..1dcda4fd5ceb2f3b2a4114533ff313a76823ac53 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -32,6 +32,7 @@ from gso.utils.helpers import ( get_router_vendor, validate_interface_name_list, validate_iptrunk_unique_interface, + validate_tt_number, ) @@ -91,6 +92,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: iptrunk_ipv4_network: ipaddress.IPv4Network = ReadOnlyField(subscription.iptrunk.iptrunk_ipv4_network) iptrunk_ipv6_network: ipaddress.IPv6Network = ReadOnlyField(subscription.iptrunk.iptrunk_ipv6_network) + @validator("tt_number", allow_reuse=True) + def validate_tt_number(cls, tt_number: str) -> str: + return validate_tt_number(tt_number) + initial_user_input = yield ModifyIptrunkForm ae_members_side_a = initialize_ae_members(subscription, initial_user_input.dict(), 0) diff --git a/gso/workflows/iptrunk/terminate_iptrunk.py b/gso/workflows/iptrunk/terminate_iptrunk.py index cd2614c7027e531cea5e1aa482e5b65c439cbb46..33ee86fde6129a05278d8985a3959e2ef945c067 100644 --- a/gso/workflows/iptrunk/terminate_iptrunk.py +++ b/gso/workflows/iptrunk/terminate_iptrunk.py @@ -16,6 +16,7 @@ from orchestrator.workflows.steps import ( unsync, ) from orchestrator.workflows.utils import wrap_modify_initial_input_form +from pydantic import validator from gso.products.product_blocks.iptrunk import IptrunkSideBlock from gso.products.product_blocks.router import RouterVendor @@ -23,7 +24,7 @@ from gso.products.product_types.iptrunk import Iptrunk from gso.services import infoblox from gso.services.netbox_client import NetboxClient from gso.services.provisioning_proxy import execute_playbook, pp_interaction -from gso.utils.helpers import get_router_vendor +from gso.utils.helpers import get_router_vendor, validate_tt_number from gso.utils.workflow_steps import set_isis_to_90000 @@ -40,6 +41,10 @@ def initial_input_form_generator() -> FormGenerator: clean_up_ipam: bool = True clean_up_netbox: bool = True + @validator("tt_number", allow_reuse=True) + def validate_tt_number(cls, tt_number: str) -> str: + return validate_tt_number(tt_number) + user_input = yield TerminateForm return user_input.dict() diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 6bec84a6a1eca393da95d68e28258e3c70d1946c..29a60dbb87d73f68f695710d3b746a8e8fa4c198 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -2,11 +2,12 @@ from typing import Any +from orchestrator.config.assignee import Assignee from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice +from orchestrator.forms.validators import Choice, Label from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr -from orchestrator.workflow import StepList, conditional, done, init, step, workflow +from orchestrator.workflow import StepList, conditional, done, init, inputstep, 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 @@ -18,7 +19,7 @@ from gso.products.product_blocks.router import ( RouterVendor, generate_fqdn, ) -from gso.products.product_types.router import RouterInactive, RouterProvisioning +from gso.products.product_types.router import RouterInactive from gso.products.product_types.site import Site from gso.services import infoblox, subscriptions from gso.services.crm import get_customer_by_name @@ -106,15 +107,16 @@ def initialize_subscription( subscription.router.vendor = vendor subscription.description = f"Router {fqdn}" - subscription = RouterProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) - return {"subscription": subscription} @step("Allocate loopback interfaces in IPAM") -def ipam_allocate_loopback(subscription: RouterProvisioning) -> State: +def ipam_allocate_loopback(subscription: RouterInactive) -> State: """Allocate :term:`IPAM` resources for the loopback interface.""" fqdn = subscription.router.router_fqdn + if not fqdn: + msg = f"Router fqdn for subscription id {subscription.subscription_id} is missing!" + raise ValueError(msg) loopback_v4, loopback_v6 = infoblox.allocate_host(f"lo0.{fqdn}", "LO", [fqdn], str(subscription.subscription_id)) subscription.router.router_lo_ipv4_address = loopback_v4 @@ -125,17 +127,21 @@ def ipam_allocate_loopback(subscription: RouterProvisioning) -> State: @step("Create NetBox Device") -def create_netbox_device(subscription: RouterProvisioning) -> State: +def create_netbox_device(subscription: RouterInactive) -> State: """Create a new NOKIA device in Netbox.""" - NetboxClient().create_device( - subscription.router.router_fqdn, - str(subscription.router.router_site.site_tier), # type: ignore[union-attr] - ) + fqdn = subscription.router.router_fqdn + site_tier = subscription.router.router_site.site_tier if subscription.router.router_site else None + if not fqdn or not site_tier: + msg = f"FQDN and/or Site tier missing in router subscription {subscription.subscription_id}!" + raise ValueError(msg) + + NetboxClient().create_device(fqdn, site_tier) + return {"subscription": subscription} @step("Verify IPAM resources for loopback interface") -def verify_ipam_loopback(subscription: RouterProvisioning) -> State: +def verify_ipam_loopback(subscription: RouterInactive) -> State: """Validate the :term:`IPAM` resources for the loopback interface.""" host_record = infoblox.find_host_by_fqdn(f"lo0.{subscription.router.router_fqdn}") if not host_record or str(subscription.subscription_id) not in host_record.comment: @@ -144,6 +150,63 @@ def verify_ipam_loopback(subscription: RouterProvisioning) -> State: return {"subscription": subscription} +@inputstep("Prompt to reboot", assignee=Assignee.SYSTEM) +def prompt_reboot_router(subscription: RouterInactive) -> FormGenerator: + """Wait for confirmation from an operator that the router has been rebooted.""" + + class RebootPrompt(FormPage): + class Config: + title = "Please reboot before continuing" + + if subscription.router.router_site and subscription.router.router_site.site_ts_address: + info_label_1: Label = ( + f"Base config has been deployed. Please log in via the console using https://" # type: ignore[assignment] + f"{subscription.router.router_site.site_ts_address}." + ) + else: + info_label_1 = "Base config has been deployed. Please log in via the console." # type: ignore[assignment] + + info_label_2: Label = "Reboot the router, and once it is up again, press submit to continue the workflow." # type: ignore[assignment] + + yield RebootPrompt + + return {} + + +@inputstep("Prompt to test the console", assignee=Assignee.SYSTEM) +def prompt_console_login() -> FormGenerator: + """Wait for confirmation from an operator that the router can be logged into.""" + + class ConsolePrompt(FormPage): + class Config: + title = "Verify local authentication" + + info_label_1: Label = ( + "Verify that you are able to log in to the router via the console using the admin account." # type: ignore[assignment] + ) + info_label_2: Label = "Once this is done, press submit to continue the workflow." # type: ignore[assignment] + + yield ConsolePrompt + + return {} + + +@inputstep("Prompt IMS insertion", assignee=Assignee.SYSTEM) +def prompt_insert_in_ims() -> FormGenerator: + """Wait for confirmation from an operator that the router has been inserted in IMS.""" + + class IMSPrompt(FormPage): + class Config: + title = "Update IMS mediation server" + + info_label_1: Label = "Insert the router into IMS." # type: ignore[assignment] + info_label_2: Label = "Once this is done, press submit to continue the workflow." # type: ignore[assignment] + + yield IMSPrompt + + return {} + + @workflow( "Create router", initial_input_form=wrap_create_initial_input_form(initial_input_form_generator), @@ -169,9 +232,12 @@ def create_router() -> StepList: >> pp_interaction(deploy_base_config_dry) >> pp_interaction(deploy_base_config_real) >> verify_ipam_loopback + >> prompt_reboot_router + >> prompt_console_login + >> prompt_insert_in_ims >> router_is_nokia(create_netbox_device) >> pp_interaction(run_checks_after_base_config) - >> set_status(SubscriptionLifecycle.ACTIVE) + >> set_status(SubscriptionLifecycle.PROVISIONING) >> resync >> done ) diff --git a/gso/workflows/router/update_ibgp_mesh.py b/gso/workflows/router/update_ibgp_mesh.py index d2f6d8d66b4dcd95250c6bcbc87f15815f3d15a6..d8ba52a09b36735bd46404a091f7f408ad4f2f66 100644 --- a/gso/workflows/router/update_ibgp_mesh.py +++ b/gso/workflows/router/update_ibgp_mesh.py @@ -2,10 +2,12 @@ from typing import Any +from orchestrator.config.assignee import Assignee from orchestrator.forms import FormPage +from orchestrator.forms.validators import Label from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, UUIDstr -from orchestrator.workflow import StepList, done, init, step, workflow +from orchestrator.workflow import StepList, done, init, inputstep, step, workflow from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form from pydantic import root_validator @@ -188,6 +190,36 @@ def add_device_to_librenms(subscription: Router) -> State: return {"librenms_device": librenms_result} +@inputstep("Prompt RADIUS insertion", assignee=Assignee.SYSTEM) +def prompt_insert_in_radius() -> FormGenerator: + """Wait for confirmation from an operator that the router has been inserted in RADIUS.""" + + class RADIUSPrompt(FormPage): + class Config: + title = "Please update RADIUS before continuing" + + info_label: Label = "Insert the router into RADIUS, and continue the workflow once this has been completed." # type: ignore[assignment] + + yield RADIUSPrompt + + return {} + + +@inputstep("Prompt RADIUS login", assignee=Assignee.SYSTEM) +def prompt_radius_login() -> FormGenerator: + """Wait for confirmation from an operator that the router can be logged into using RADIUS.""" + + class RADIUSPrompt(FormPage): + class Config: + title = "Please check RADIUS before continuing" + + info_label: Label = "Log in to the router using RADIUS, and continue the workflow when this was successful." # type: ignore[assignment] + + yield RADIUSPrompt + + return {} + + @step("Update subscription model") def update_subscription_model(subscription: Router) -> State: """Update the database model, such that it should not be reached via :term:`OOB` access anymore.""" @@ -221,6 +253,8 @@ def update_ibgp_mesh() -> StepList: >> pp_interaction(add_all_pe_to_p_real) >> pp_interaction(check_ibgp_session) >> add_device_to_librenms + >> prompt_insert_in_radius + >> prompt_radius_login >> update_subscription_model >> resync >> done diff --git a/setup.py b/setup.py index 85e9b36696de7be28dea8acf89e2fddaa74cbcba..cf34f158746312524a27348396cf48a46812080d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="0.6", + version="0.7", author="GÉANT", author_email="swd@geant.org", description="GÉANT Service Orchestrator", diff --git a/test/conftest.py b/test/conftest.py index dd6e9d6d3fc3429f0ce0df33213de8dc1f195523..b0e8ddbd1ff3a99521d1540d92f2ad519f253131 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -120,6 +120,7 @@ def configuration_data() -> dict: }, "domain_name": ".geant.net", "dns_view": "default", + "network_view": "default", }, "TRUNK": { "V4": { @@ -134,6 +135,7 @@ def configuration_data() -> dict: }, "domain_name": ".trunk", "dns_view": "default", + "network_view": "default", }, "GEANT_IP": { "V4": { @@ -148,6 +150,7 @@ def configuration_data() -> dict: }, "domain_name": ".geantip", "dns_view": "default", + "network_view": "default", }, "SI": { "V4": { @@ -158,6 +161,7 @@ def configuration_data() -> dict: "V6": {"containers": [], "networks": [], "mask": 126}, "domain_name": ".geantip", "dns_view": "default", + "network_view": "default", }, "LT_IAS": { "V4": { @@ -172,6 +176,7 @@ def configuration_data() -> dict: }, "domain_name": ".geantip", "dns_view": "default", + "network_view": "default", }, }, "MONITORING": { @@ -208,6 +213,14 @@ def configuration_data() -> dict: "AnsibleDynamicInventoryGenerator": "REALY_random_AND_3cure_T0keN", "Application_2": "another_REALY_random_AND_3cure_T0keN", }, + "EMAIL": { + "from_address": "noreply@nren.local", + "smtp_host": "smtp.nren.local", + "smtp_port": 487, + "starttls_enabled": True, + "smtp_username": "username", + "smtp_password": "password", + }, } diff --git a/test/utils/test_helpers.py b/test/utils/test_helpers.py index d5fb22827da3cc207bde920585a72d1ff05ded89..4006c4ad50101e93b10ca16c802761612a244ea7 100644 --- a/test/utils/test_helpers.py +++ b/test/utils/test_helpers.py @@ -4,7 +4,7 @@ import pytest from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock from gso.products.product_blocks.router import RouterVendor -from gso.utils.helpers import available_interfaces_choices_including_current_members +from gso.utils.helpers import available_interfaces_choices_including_current_members, validate_tt_number @pytest.fixture() @@ -21,6 +21,18 @@ def mock_netbox_client(): yield mock +@pytest.fixture() +def generate_tt_numbers(faker, request): + """Generator for valid and invalid tt numbers.""" + valid_count = request.param.get("valid", 0) + invalid_count = request.param.get("invalid", 0) + + valid_data = [(faker.tt_number(), True) for _ in range(valid_count)] + invalid_data = [(faker.sentence(), False) for _ in range(invalid_count)] + + return valid_data + invalid_data + + def test_non_nokia_router_returns_none(mock_router, faker): mock_router.from_subscription.return_value.router.vendor = RouterVendor.JUNIPER result = available_interfaces_choices_including_current_members(faker.uuid4(), "10G", []) @@ -62,3 +74,19 @@ def test_nokia_router_with_interfaces_returns_choice(mock_router, mock_netbox_cl assert hasattr(result, "interface1") assert hasattr(result, "interface2") assert hasattr(result, "interface3") + + +@pytest.mark.parametrize("generate_tt_numbers", [{"valid": 5, "invalid": 3}], indirect=True) +def test_tt_number(generate_tt_numbers): + """Test different TT numbers""" + for tt_number, is_valid in generate_tt_numbers: + if is_valid: + assert validate_tt_number(tt_number) == tt_number + else: + err_msg = ( + f"The given TT number: {tt_number} is not valid. " + f" A valid TT number starts with 'TT#' followed by 16 digits." + ) + + with pytest.raises(ValueError, match=err_msg): + validate_tt_number(tt_number) diff --git a/test/workflows/__init__.py b/test/workflows/__init__.py index 669fd75cf6b91057d0c327b0dbc111abb63e6f8f..5470858c105666ac8396b42649c65a02eeef8d0c 100644 --- a/test/workflows/__init__.py +++ b/test/workflows/__init__.py @@ -140,7 +140,7 @@ class WorkflowInstanceForTests(LazyWorkflowInstance): This can be as simple as merely importing a workflow function. However, if it concerns a workflow generating function, that function will be called with or without arguments as specified. - Returns: A workflow function. + :return Workflow: A workflow function. """ self.workflow.name = self.name return self.workflow diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index 59ecc85e9e5fc3901ebf519246c9ca6fdb0a95ec..c069618476f87b4beafcf4fbcb89f259fc043071 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -129,7 +129,7 @@ def test_successful_iptrunk_creation_with_standard_lso_result( subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, ] ) - assert subscription.status == "active" + assert subscription.status == "provisioning" assert subscription.description == ( f"IP trunk {sorted_sides[0]} {sorted_sides[1]}, geant_s_sid:{input_form_wizard_data[0]['geant_s_sid']}" ) diff --git a/test/workflows/router/test_create_router.py b/test/workflows/router/test_create_router.py index 957c3bda41026db88a7d7fc70ce2efa556770774..ca012757153478de3ca4e50fa37b44daa9696595 100644 --- a/test/workflows/router/test_create_router.py +++ b/test/workflows/router/test_create_router.py @@ -7,11 +7,14 @@ from gso.products import ProductType, Site from gso.products.product_blocks.router import RouterRole, RouterVendor from gso.products.product_types.router import Router from gso.services.subscriptions import get_product_id_by_name +from test import USER_CONFIRM_EMPTY_FORM from test.workflows import ( assert_complete, assert_pp_interaction_failure, assert_pp_interaction_success, + assert_suspended, extract_state, + resume_workflow, run_workflow, ) @@ -80,15 +83,22 @@ def test_create_nokia_router_success( name=mock_fqdn, ) - for _ in range(3): + for _ in range(2): result, step_log = assert_pp_interaction_success(result, process_stat, step_log) + # Handle three consecutive user input steps + for _ in range(3): + assert_suspended(result) + result, step_log = resume_workflow(process_stat, step_log, input_data=USER_CONFIRM_EMPTY_FORM) + + result, step_log = assert_pp_interaction_success(result, process_stat, step_log) + assert_complete(result) state = extract_state(result) subscription = Router.from_subscription(subscription_id) - assert subscription.status == "active" + assert subscription.status == "provisioning" assert subscription.description == f"Router {mock_fqdn}" assert mock_provision_router.call_count == 3 @@ -167,7 +177,7 @@ def test_create_nokia_router_lso_failure( assert_pp_interaction_failure(result, process_stat, step_log) - assert subscription.status == "provisioning" + assert subscription.status == "initial" assert subscription.description == f"Router {mock_fqdn}" assert mock_provision_router.call_count == 2 diff --git a/test/workflows/router/test_update_ibgp_mesh.py b/test/workflows/router/test_update_ibgp_mesh.py index 14543066130d06d5d6f5d148b2d126a44d267adc..8c7da5d5d0abb2a4275f52e2c89cc2c52bc7f51e 100644 --- a/test/workflows/router/test_update_ibgp_mesh.py +++ b/test/workflows/router/test_update_ibgp_mesh.py @@ -6,7 +6,8 @@ from pydantic_forms.exceptions import FormValidationError from gso.products import Iptrunk from gso.products.product_blocks.router import RouterRole -from test.workflows import assert_pp_interaction_success, extract_state, run_workflow +from test import USER_CONFIRM_EMPTY_FORM +from test.workflows import assert_pp_interaction_success, assert_suspended, extract_state, resume_workflow, run_workflow @pytest.fixture() @@ -33,6 +34,11 @@ def test_update_ibgp_mesh_success( for _ in range(5): result, step_log = assert_pp_interaction_success(result, process_stat, step_log) + # Handle two consecutive user input steps + for _ in range(2): + assert_suspended(result) + result, step_log = resume_workflow(process_stat, step_log, input_data=USER_CONFIRM_EMPTY_FORM) + state = extract_state(result) assert mock_execute_playbook.call_count == 5