diff --git a/Changelog.md b/Changelog.md index d51bd0965f45cdf749cef2b164f513fac6e34e16..f30551fb13bda0c07651e3fe21b783276c4a59d6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [2.9] - 2024-08-06 +- IP trunk validation workflow no longer runs for Juniper-only trunks +- Automatically run pre- and post-checks when modifying an IP trunk +- Small improvements to email notifications + ## [2.8] - 2024-08-01 - Reworked authentication components - Add a task for sending email notifications in case validation workflows have failed diff --git a/gso/__init__.py b/gso/__init__.py index 2df27bcb762638dee24e7c6449b34f7e99b4782d..f1d1debdc8208a7368f90d811d586679367f9cad 100644 --- a/gso/__init__.py +++ b/gso/__init__.py @@ -47,8 +47,10 @@ def init_cli_app() -> typer.Typer: def init_sentry() -> None: """Only initialize Sentry if not in testing mode.""" - if os.getenv("TESTING", "false").lower() == "false" and (sentry_config := load_oss_params().SENTRY): - sentry_sdk.init(dsn=sentry_config.DSN, environment=sentry_config.environment, traces_sample_rate=1.0) + if os.getenv("TESTING", "false").lower() == "false" and (sentry_params := load_oss_params().SENTRY): + sentry_sdk.init( + dsn=sentry_params.DSN, environment=load_oss_params().GENERAL.environment, traces_sample_rate=1.0 + ) init_sentry() diff --git a/gso/api/v1/network.py b/gso/api/v1/network.py index 9109fd6fcde9ca14825b64d506082fb68037eb54..9b385c8e86330e5929a8e7b1a882faf07b966da0 100644 --- a/gso/api/v1/network.py +++ b/gso/api/v1/network.py @@ -62,6 +62,7 @@ class IptrunkBlock(OrchestratorBaseModel): iptrunk_capacity: str iptrunk_isis_metric: int iptrunk_sides: list[IptrunkSideBlock] + geant_s_sid: str class IptrunkSchema(OrchestratorBaseModel): @@ -106,6 +107,7 @@ def network_topology() -> NetworkTopologyDomainModelSchema: "iptrunk_capacity": _calculate_iptrunk_capacity( extended_model["iptrunk"]["iptrunk_sides"], extended_model["iptrunk"]["iptrunk_speed"] ), + "geant_s_sid": extended_model["iptrunk"]["geant_s_sid"], "iptrunk_sides": [ { "subscription_instance_id": side["subscription_instance_id"], diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json index f9523a724d2b433b761ef4c5f33544077e6d850b..069672ffda3c401e850b7d146d37259898ebbf46 100644 --- a/gso/oss-params-example.json +++ b/gso/oss-params-example.json @@ -1,7 +1,9 @@ { "GENERAL": { "public_hostname": "https://gap.geant.org", - "isis_high_metric": 999999 + "internal_hostname": "http://gso-api:9000", + "isis_high_metric": 999999, + "environment": "development" }, "NETBOX": { "api": "https://127.0.0.1:8000", @@ -119,7 +121,6 @@ "md5_password": "snmp password" }, "SENTRY": { - "DSN": "https://sentry-dsn-url", - "environment": "development" + "DSN": "https://sentry-dsn-url" } } diff --git a/gso/services/lso_client.py b/gso/services/lso_client.py index 6e1ec8fa039dfddbe12bacf3e4c5b215ef7c3635..6eca3f6559943df80660b3f71ba50e9c1feb1d64 100644 --- a/gso/services/lso_client.py +++ b/gso/services/lso_client.py @@ -37,7 +37,7 @@ def _send_request(parameters: dict, callback_route: str) -> None: params = oss.PROVISIONING_PROXY # Build up a callback URL of the Provisioning Proxy to return its results to. - callback_url = f"{oss.GENERAL.public_hostname}{callback_route}" + callback_url = f"{oss.GENERAL.internal_hostname}{callback_route}" debug_msg = f"[provisioning proxy] Callback URL set to {callback_url}" logger.debug(debug_msg) diff --git a/gso/settings.py b/gso/settings.py index c105fffc8fbb23933a12a572f208d0bd07cd5b9e..6c6378dd196915b26626a80ac31f8282a795791d 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -21,13 +21,24 @@ from gso.utils.shared_enums import PortNumber logger = logging.getLogger(__name__) +class EnvironmentEnum(strEnum): + """The different environments in which the GSO system can run.""" + + DEVELOPMENT = "development" + TEST = "test" + UAT = "uat" + PRODUCTION = "production" + + class GeneralParams(BaseSettings): """General parameters for a :term:`GSO` configuration file.""" public_hostname: str - """The hostname that :term:`GSO` is publicly served at, used for building the callback URL that the provisioning - proxy uses.""" + """The hostname that :term:`GSO` is publicly served at, used for building callback URLs for public use.""" + internal_hostname: str + """The hostname of :term:`GSO` that is for internal use, such as the provisioning proxy.""" isis_high_metric: int + environment: EnvironmentEnum class CeleryParams(BaseSettings): @@ -192,20 +203,10 @@ class KentikParams(BaseSettings): md5_password: str -class EnvironmentEnum(strEnum): - """The different environments in which the GSO system can run.""" - - DEVELOPMENT = "development" - TEST = "test" - UAT = "uat" - PRODUCTION = "production" - - class SentryParams(BaseSettings): """Settings for Sentry.""" DSN: str - environment: EnvironmentEnum class OSSParams(BaseSettings): diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index d64331720fcb85cccc829f58d6d3d8b437e55b77..7ed4c0bc110c827aa5e7880f5d723d8a8b28e549 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -34,6 +34,8 @@ from gso.utils.helpers import ( validate_tt_number, ) from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, Vendor +from gso.workflows.iptrunk.migrate_iptrunk import check_ip_trunk_optical_levels_pre +from gso.workflows.iptrunk.validate_iptrunk import check_ip_trunk_isis T = TypeVar("T", bound=LAGMember) @@ -175,6 +177,69 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) +@step("Determine whether we should be running interface checks") +def determine_change_in_capacity( + subscription: Iptrunk, iptrunk_speed: str, side_a_ae_members: list[LAGMember], side_b_ae_members: list[LAGMember] +) -> State: + """Determine whether we should run pre- and post-checks on the IP trunk. + + This can be caused by the following conditions: + * The total capacity of the trunk changes + * The amount of interfaces changes + * One or more interface names have changed on side A + * One or more interface names have changed on side B + """ + capacity_has_changed = ( + iptrunk_speed != subscription.iptrunk.iptrunk_speed + or len(side_a_ae_members) != len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) + or any( + old_interface.interface_name != new_interface.interface_name + for old_interface, new_interface in zip( + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members, side_a_ae_members, strict=False + ) + ) + or any( + old_interface.interface_name != new_interface.interface_name + for old_interface, new_interface in zip( + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members, side_b_ae_members, strict=False + ) + ) + ) + + return {"capacity_has_changed": capacity_has_changed} + + +@step("Check IP connectivity of the trunk") +def check_ip_trunk_connectivity(subscription: Iptrunk, callback_route: str) -> State: + """Check successful connectivity across a trunk.""" + extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "ping"} + + execute_playbook( + playbook_name="iptrunks_checks.yaml", + callback_route=callback_route, + inventory=subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn, + extra_vars=extra_vars, + ) + + return {"subscription": subscription} + + +@step("Check LLDP on the trunk endpoints") +def check_ip_trunk_lldp(subscription: Iptrunk, callback_route: str) -> State: + """Check LLDP on trunk endpoints.""" + extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "lldp"} + + execute_playbook( + playbook_name="iptrunks_checks.yaml", + callback_route=callback_route, + inventory=f"{subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn}\n" + f"{subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}\n", + extra_vars=extra_vars, + ) + + return {"subscription": subscription} + + @step("Update subscription") def modify_iptrunk_subscription( subscription: Iptrunk, @@ -397,6 +462,22 @@ def allocate_interfaces_in_netbox_side_b(subscription: Iptrunk, previous_ae_memb _netbox_allocate_interfaces(subscription.iptrunk.iptrunk_sides[1], previous_ae_members[1]) +@step("Check Optical POST levels on the trunk endpoint") +def check_ip_trunk_optical_levels_post(subscription: Iptrunk, callback_route: str) -> State: + """Check Optical POST levels on the trunk.""" + extra_vars = {"wfo_ip_trunk_json": json.loads(json_dumps(subscription)), "check": "optical_post"} + + execute_playbook( + playbook_name="iptrunks_checks.yaml", + callback_route=callback_route, + inventory=f"{subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn}\n" + f"{subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn}\n", + extra_vars=extra_vars, + ) + + return {"subscription": subscription} + + @workflow( "Modify IP Trunk interface", initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator), @@ -422,10 +503,17 @@ def modify_trunk_interface() -> StepList: ) == Vendor.NOKIA ) + capacity_has_changed = conditional(lambda state: state["capacity_has_changed"]) + return ( begin >> store_process_subscription(Target.MODIFY) >> unsync + >> determine_change_in_capacity + >> capacity_has_changed(lso_interaction(check_ip_trunk_lldp)) + >> capacity_has_changed(lso_interaction(check_ip_trunk_optical_levels_pre)) + >> capacity_has_changed(lso_interaction(check_ip_trunk_connectivity)) + >> capacity_has_changed(lso_interaction(check_ip_trunk_isis)) >> modify_iptrunk_subscription >> side_a_is_nokia(netbox_update_interfaces_side_a) >> side_b_is_nokia(netbox_update_interfaces_side_b) @@ -433,6 +521,10 @@ def modify_trunk_interface() -> StepList: >> lso_interaction(provision_ip_trunk_iface_real) >> side_a_is_nokia(allocate_interfaces_in_netbox_side_a) >> side_b_is_nokia(allocate_interfaces_in_netbox_side_b) + >> capacity_has_changed(lso_interaction(check_ip_trunk_lldp)) + >> capacity_has_changed(lso_interaction(check_ip_trunk_optical_levels_post)) + >> capacity_has_changed(lso_interaction(check_ip_trunk_connectivity)) + >> capacity_has_changed(lso_interaction(check_ip_trunk_isis)) >> resync >> done ) diff --git a/gso/workflows/iptrunk/validate_iptrunk.py b/gso/workflows/iptrunk/validate_iptrunk.py index 5b2e3f50cf4615962096af32f4838579f418a4cc..bdfc4d3b952eca2e998d46ceab47250a56cfdb21 100644 --- a/gso/workflows/iptrunk/validate_iptrunk.py +++ b/gso/workflows/iptrunk/validate_iptrunk.py @@ -5,7 +5,7 @@ import json from orchestrator.targets import Target from orchestrator.utils.errors import ProcessFailureError from orchestrator.utils.json import json_dumps -from orchestrator.workflow import StepList, begin, done, step, workflow +from orchestrator.workflow import StepList, begin, conditional, done, step, workflow from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form @@ -192,10 +192,20 @@ def validate_iptrunk() -> StepList: * Verify the configuration on both sides of the trunk is intact. * Check the ISIS metric of the trunk. * Verify that TWAMP configuration is correct. + + If a trunk has a Juniper router on both sides, it is considered legacy and does not require validation. """ + skip_legacy_trunks = conditional( + lambda state: all( + side.iptrunk_side_node.vendor == Vendor.JUNIPER + for side in Iptrunk.from_subscription(state["subscription_id"]).iptrunk.iptrunk_sides + ) + ) + return ( begin >> store_process_subscription(Target.SYSTEM) + >> skip_legacy_trunks(done) >> unsync >> verify_ipam_records >> verify_netbox_entries diff --git a/gso/workflows/tasks/send_email_notifications.py b/gso/workflows/tasks/send_email_notifications.py index e30916cc69ac75ca3114fd46ae94fb6ccf275f1f..f346cf1f5d14db2f1b00e9307ae22b83b609066d 100644 --- a/gso/workflows/tasks/send_email_notifications.py +++ b/gso/workflows/tasks/send_email_notifications.py @@ -18,10 +18,10 @@ def gather_failed_tasks() -> State: @step("Send notification emails for all failed tasks") def send_email_notifications(state: State) -> None: """Send out an email notification for all tasks that have failed.""" - base_url = load_oss_params().GENERAL.public_hostname + general_settings = load_oss_params().GENERAL all_alerts = "" for failure in state["failed_tasks"]: - failed_task_url = f"{base_url}/workflows/{failure["process_id"]}" + failed_task_url = f"{general_settings.public_hostname}/workflows/{failure["process_id"]}" failed_subscription = get_subscription_by_process_id(failure["process_id"]) all_alerts = f"{all_alerts}------\n\n" if failed_subscription: @@ -36,7 +36,7 @@ def send_email_notifications(state: State) -> None: ) send_mail( - "GAP - One or more tasks have failed!", + f"GAP {general_settings.environment} environment - One or more tasks have failed!", ( f"Please check the following tasks in GAP which have failed.\n\n{all_alerts}------" f"\n\nRegards, the GÉANT Automation Platform.\n\n" diff --git a/setup.py b/setup.py index a70761c82a2232f6bd63b2cb01f9deb5e1f0a184..05d2fd0f212ec9bb1c4a0edf367be14b60750dd7 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="2.8", + version="2.9", author="GÉANT Orchestration and Automation Team", author_email="goat@geant.org", description="GÉANT Service Orchestrator", diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py index 2a95cc90fc6428dd19c7e9a2b0a0f77a58ef3a2b..1383ea2edc1380613900ae62b0024b4622ac6d57 100644 --- a/test/workflows/iptrunk/test_modify_trunk_interface.py +++ b/test/workflows/iptrunk/test_modify_trunk_interface.py @@ -90,7 +90,7 @@ def input_form_iptrunk_data( indirect=True, ) @pytest.mark.workflow() -@patch("gso.workflows.iptrunk.modify_trunk_interface.execute_playbook") +@patch("gso.services.lso_client._send_request") @patch("gso.services.netbox_client.NetboxClient.get_available_interfaces") @patch("gso.services.netbox_client.NetboxClient.attach_interface_to_lag") @patch("gso.services.netbox_client.NetboxClient.reserve_interface") @@ -120,8 +120,10 @@ def test_iptrunk_modify_trunk_interface_success( # Run workflow result, process_stat, step_log = run_workflow("modify_trunk_interface", input_form_iptrunk_data) + state = extract_state(result) + lso_interaction_count = 10 if state["capacity_has_changed"] else 2 - for _ in range(2): + for _ in range(lso_interaction_count): result, step_log = assert_lso_interaction_success(result, process_stat, step_log) assert_complete(result) @@ -131,7 +133,7 @@ def test_iptrunk_modify_trunk_interface_success( subscription = Iptrunk.from_subscription(subscription_id) assert subscription.status == "active" - assert mock_provision_ip_trunk.call_count == 2 + assert mock_provision_ip_trunk.call_count == lso_interaction_count # Assert all Netbox calls have been made new_sid = input_form_iptrunk_data[1]["geant_s_sid"] new_side_a_sid = input_form_iptrunk_data[3]["side_a_ae_geant_a_sid"] diff --git a/test/workflows/iptrunk/test_validate_iptrunk.py b/test/workflows/iptrunk/test_validate_iptrunk.py index 48de3d1bc069f879ae307273544abbeef57ebc5b..13bcaed9b4be4284a6f1dc5fa4e41410d2f8dab6 100644 --- a/test/workflows/iptrunk/test_validate_iptrunk.py +++ b/test/workflows/iptrunk/test_validate_iptrunk.py @@ -4,6 +4,7 @@ import pytest from infoblox_client import objects from gso.products.product_types.iptrunk import Iptrunk +from test.conftest import UseJuniperSide from test.services.conftest import MockedNetboxClient from test.workflows import ( assert_complete, @@ -13,6 +14,31 @@ from test.workflows import ( ) +@pytest.fixture() +def trunk_validation_setup( + request, + iptrunk_subscription_factory, + juniper_router_subscription_factory, + nokia_router_subscription_factory, + iptrunk_side_subscription_factory, +): + if request.param == UseJuniperSide.SIDE_A: + # Nokia -> Juniper + side_a = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory()) + side_b = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory()) + elif request.param == UseJuniperSide.SIDE_B: + # Juniper -> Nokia + side_a = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory()) + side_b = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory()) + else: + # Nokia -> Nokia + side_a = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory()) + side_b = iptrunk_side_subscription_factory(iptrunk_side_node=nokia_router_subscription_factory()) + + subscription_id = iptrunk_subscription_factory(iptrunk_sides=[side_a, side_b]) + return [{"subscription_id": subscription_id}] + + @pytest.fixture() def _mocked_netbox_client(): with ( @@ -35,6 +61,9 @@ def _mocked_netbox_client(): yield +@pytest.mark.parametrize( + "trunk_validation_setup", [UseJuniperSide.NONE, UseJuniperSide.SIDE_A, UseJuniperSide.SIDE_B], indirect=True +) @pytest.mark.workflow() @pytest.mark.usefixtures("_mocked_netbox_client") @patch("gso.services.infoblox.find_network_by_cidr") @@ -50,10 +79,10 @@ def test_validate_iptrunk_success( mock_find_network_by_cidr, faker, data_config_filename, - iptrunk_subscription_factory, + trunk_validation_setup, ): # Mock value setup - subscription_id = iptrunk_subscription_factory() + subscription_id = trunk_validation_setup[0]["subscription_id"] trunk = Iptrunk.from_subscription(subscription_id).iptrunk mock_find_network_by_cidr.side_effects = [ objects.Network(connector=None, ipv4addrs=[trunk.iptrunk_ipv4_network]), @@ -183,3 +212,44 @@ def test_validate_iptrunk_success( assert mock_find_host_by_fqdn.call_count == 2 assert mock_find_v6_host_by_fqdn.call_count == 2 assert mock_find_network_by_cidr.call_count == 2 + + +@pytest.mark.workflow() +@pytest.mark.usefixtures("_mocked_netbox_client") +@patch("gso.services.infoblox.find_network_by_cidr") +@patch("gso.services.infoblox.find_v6_host_by_fqdn") +@patch("gso.services.infoblox.find_host_by_fqdn") +@patch("gso.workflows.iptrunk.validate_iptrunk.execute_playbook") +@patch("gso.services.netbox_client.NetboxClient.get_interface_by_name_and_device") +def test_validate_iptrunk_skip_legacy_trunks( + mock_get_interface_by_name, + mock_validate_iptrunk, + mock_find_host_by_fqdn, + mock_find_v6_host_by_fqdn, + mock_find_network_by_cidr, + faker, + data_config_filename, + iptrunk_subscription_factory, + iptrunk_side_subscription_factory, + juniper_router_subscription_factory, +): + # Mock value setup + side_a = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory()) + side_b = iptrunk_side_subscription_factory(iptrunk_side_node=juniper_router_subscription_factory()) + subscription_id = iptrunk_subscription_factory(iptrunk_sides=[side_a, side_b]) + + # Run workflow + initial_router_data = [{"subscription_id": subscription_id}] + result, _, _ = run_workflow("validate_iptrunk", initial_router_data) + state = extract_state(result) + assert_complete(result) + + subscription_id = state["subscription_id"] + subscription = Iptrunk.from_subscription(subscription_id) + + assert subscription.status == "active" + assert mock_get_interface_by_name.call_count == 0 + assert mock_validate_iptrunk.call_count == 0 + assert mock_find_host_by_fqdn.call_count == 0 + assert mock_find_v6_host_by_fqdn.call_count == 0 + assert mock_find_network_by_cidr.call_count == 0