Skip to content
Snippets Groups Projects
Commit af50d2db authored by Hakan Calim's avatar Hakan Calim
Browse files

MAT-410: merged develop

parents 3508a215 4bf38511
No related branches found
No related tags found
1 merge request!159Feature/nat 410 add validator for tty number
Showing
with 257 additions and 86 deletions
......@@ -9,7 +9,9 @@ repos:
- --fix
- --preview
- --ignore=PLR0917,PLR0914
- --extend-exclude=test/*
# Run the formatter.
- id: ruff-format
args:
- --preview
- --exclude=test/*
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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
......@@ -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"
}
}
......@@ -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
......
......@@ -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
"""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)
......@@ -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:
......
......@@ -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]
......
......@@ -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
)
......@@ -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
)
......@@ -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
......
......@@ -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",
},
}
......
......@@ -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
......
......@@ -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']}"
)
......
......@@ -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
......
......@@ -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
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment