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/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..007b899bf956cca6cdf148d79001637d181b5939 100644 --- a/gso/oss-params-example.json +++ b/gso/oss-params-example.json @@ -77,5 +77,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/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..0189986d9cdf15c347393f2a55c1f23944922b36 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -152,6 +152,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 +174,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 463d5d5d84a369fad779366008deff1fd4d34601..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] diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index 80a9431a172b75d461c69d850bff82f5d767f236..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 @@ -185,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", @@ -243,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, @@ -278,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, @@ -306,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.""" @@ -324,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, @@ -352,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, @@ -380,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.""" @@ -397,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: @@ -429,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]) @@ -485,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/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/test/conftest.py b/test/conftest.py index dd6e9d6d3fc3429f0ce0df33213de8dc1f195523..895b4798208a936c137ecd32358cd38cbeb42617 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -208,6 +208,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/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