diff --git a/Changelog.md b/Changelog.md index 33899bee32a7cb71760225be423e7dafa94e4880..db9c93808bcda05ecc040fd654f472d50222144a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. +## [0.145] - 2025-05-27 +- NGM-15/19/20: added /map/pops & /map/equipment + ## [0.145] - 2025-05-20 - DBOARD3-1238: Updated OID for Juniper Active State Checking diff --git a/inventory_provider/__init__.py b/inventory_provider/__init__.py index 3155d6cce97b98ca09e7942da6806f23292a2181..fb6aa000e7510036de79c6f532c68192a4f62a1f 100644 --- a/inventory_provider/__init__.py +++ b/inventory_provider/__init__.py @@ -93,6 +93,9 @@ def create_app(setup_logging=True): from inventory_provider.routes import state_checker app.register_blueprint(state_checker.routes, url_prefix='/state-checker') + from inventory_provider.routes import map + app.register_blueprint(map.routes, url_prefix='/map') + if app.config.get('ENABLE_TESTING_ROUTES', False): from inventory_provider.routes import testing app.register_blueprint(testing.routes, url_prefix='/testing') diff --git a/inventory_provider/gap.py b/inventory_provider/gap.py index 0a7b1de811f712cf2051029ea97fc59406ac472c..7c5f72e745f0a3579b81e08452f628e7a720b932 100644 --- a/inventory_provider/gap.py +++ b/inventory_provider/gap.py @@ -75,20 +75,7 @@ def make_request(body: dict, token: str, app_config: dict) -> Dict: return response.json() -def extract_router_info(device: dict, token: str, app_config: dict) -> Optional[dict]: - tag_to_key_map = { - "RTR": "router", - "OFFICE_ROUTER": "officeRouter", - "Super_POP_SWITCH": "superPopSwitch" - } - - tag = device.get("product", {}).get("tag") - key = tag_to_key_map.get(tag) - subscription_id = device.get("subscriptionId") - - if key is None or subscription_id is None: - logger.warning(f"Skipping device with invalid tag or subscription ID: {device}") - return None +def load_subscription_product_blocks(subscription_id: str, token: str, app_config: dict) -> dict: query = f""" query {{ @@ -109,25 +96,107 @@ def extract_router_info(device: dict, token: str, app_config: dict) -> Optional[ page_data = response.get('data', {}).get('subscriptions', {}).get('page') if not page_data: + return {} + + def _product_block_instances(): + for _page_chunk in page_data: + yield from _page_chunk.get('productBlockInstances') + # yield from _page_chunk.get('productBlockInstances', [{}]) + + def _product_block_items(): + + for _pb_instance in _product_block_instances(): + _pb_instance_values = _pb_instance.get('productBlockInstanceValues') + # wfo format sanity check ... + assert len(_pb_instance_values) >= 2, \ + f'Expected at least 2 items in product block instance, got {len(_pb_instance_values)}' + _iterator = iter(_pb_instance_values) + name_item = next(_iterator) + assert name_item.get('field') == 'name' + + # unused, but sanity check ... + _unused_label_item = next(_iterator) + assert _unused_label_item.get('field') == 'label' + + value_dict = {_it.get('field'): _it.get('value') for _it in _iterator} + yield name_item.get('value'), value_dict + + return dict(_product_block_items()) + + +def extract_site_info(subscription_id: str, token: str, app_config: dict) -> Optional[dict]: + product_blocks = load_subscription_product_blocks( + subscription_id=subscription_id, + token=token, + app_config=app_config) + + if not product_blocks: logger.warning(f"No data for subscription ID: {subscription_id}") + return {} + + if not product_blocks: + logger.warning(f"No data for subscription ID: {subscription_id}") + return {} + + def _find_pb_value(field): + for _pb_values in product_blocks.values(): + if field in _pb_values: + return _pb_values[field] return None - instance_values = page_data[0].get('productBlockInstances', [{}])[0].get('productBlockInstanceValues', []) + return { + 'city': _find_pb_value('siteCity'), + 'country': _find_pb_value('siteCountry'), + 'country_code': _find_pb_value('siteCountryCode'), + 'name': _find_pb_value('siteName'), + 'longitude': float(_find_pb_value('siteLongitude')), + 'latitude': float(_find_pb_value('siteLatitude')), + 'tier': int(_find_pb_value('siteTier')), + } + - fqdn = next((item.get('value') for item in instance_values if item.get('field') == f'{key}Fqdn'), None) - vendor = next((item.get('value') for item in instance_values if item.get('field') == 'vendor'), None) +def extract_router_info(device_subscription: dict, token: str, app_config: dict) -> Optional[dict]: + tag_to_key_map = { + "RTR": "router", + "OFFICE_ROUTER": "officeRouter", + "Super_POP_SWITCH": "superPopSwitch" + } - if fqdn and vendor: - return {'fqdn': fqdn, 'vendor': vendor} + tag = device_subscription.get("product", {}).get("tag") + key = tag_to_key_map.get(tag) + subscription_id = device_subscription.get("subscriptionId") + + if key is None or subscription_id is None: + logger.warning(f"Skipping device with invalid tag or subscription ID: {device_subscription}") + return None + + product_blocks = load_subscription_product_blocks( + subscription_id=subscription_id, + token=token, + app_config=app_config) + + if not product_blocks: + logger.warning(f"No data for subscription ID: {subscription_id}") + return {} + + def _find_pb_value(field): + for _pb_values in product_blocks.values(): + if field in _pb_values: + return _pb_values[field] + return None + + fqdn = _find_pb_value(f'{key}Fqdn',) + vendor = _find_pb_value('vendor') + site = _find_pb_value('siteName') + + if fqdn and vendor and site: + return {'fqdn': fqdn, 'vendor': vendor, 'site': site} else: - logger.warning(f"Skipping device with missing FQDN or vendor: {device}") + logger.warning(f"Skipping device with missing FQDN or vendor: {device_subscription}") return None -def load_routers_from_orchestrator(app_config: dict) -> Dict: - """Gets devices from the orchestrator and returns a dictionary of FQDNs and vendors.""" - token = get_token(app_config['aai']) - routers = {} +def _retrieve_subscriptions(token: str, app_config: dict, filter_by: str, subscription_query: str): end_cursor = 0 has_next_page = True @@ -135,10 +204,10 @@ def load_routers_from_orchestrator(app_config: dict) -> Dict: query = f""" {{ subscriptions( - filterBy: {{field: "status", value: "PROVISIONING|ACTIVE"}}, + filterBy: {filter_by}, first: 100, after: {end_cursor}, - query: "tag:(RTR|OFFICE_ROUTER|Super_POP_SWITCH)" + query: "{subscription_query}" ) {{ pageInfo {{ hasNextPage @@ -164,11 +233,60 @@ def load_routers_from_orchestrator(app_config: dict) -> Dict: devices = [] has_next_page = False - with concurrent.futures.ThreadPoolExecutor() as executor: - futures = [executor.submit(extract_router_info, device, token, app_config) for device in devices] - for future in concurrent.futures.as_completed(futures): - router_info = future.result() - if router_info is not None: - routers[router_info['fqdn']] = router_info['vendor'] + yield from devices + + +def load_sites_from_orchestrator(app_config: dict) -> Dict: + """Gets sites from the orchestrator and returns a dictionary of FQDNs and vendors.""" + token = get_token(app_config['aai']) + subscriptions = _retrieve_subscriptions( + token=token, + app_config=app_config, + filter_by='{field: "status", value: "PROVISIONING|ACTIVE"}', + subscription_query="tag:SITE") + + sites = [] + with concurrent.futures.ThreadPoolExecutor() as executor: + + futures = [ + executor.submit( + extract_site_info, + subscription.get("subscriptionId"), + token, + app_config) + for subscription in subscriptions] + + for future in concurrent.futures.as_completed(futures): + site_info = future.result() + if site_info is not None: + sites.append(site_info) + # routers[router_info['fqdn']] = router_info['vendor'] + + return sites + + +def load_routers_from_orchestrator(app_config: dict) -> Dict: + """Gets devices from the orchestrator and returns a dictionary of FQDNs and vendors.""" + + token = get_token(app_config['aai']) + + subscriptions = _retrieve_subscriptions( + token=token, + app_config=app_config, + filter_by='{field: "status", value: "PROVISIONING|ACTIVE"}', + subscription_query="tag:(RTR|OFFICE_ROUTER|Super_POP_SWITCH)") + routers = [] + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + extract_router_info, + subscription, + token, + app_config) + for subscription in subscriptions] + for future in concurrent.futures.as_completed(futures): + router_info = future.result() + if router_info is not None: + routers.append(router_info) return routers diff --git a/inventory_provider/routes/__init__.py b/inventory_provider/routes/__init__.py index 4cd55dfe6fb4089cb72a0b3ebed1c07d0c7eddb3..bbf6f098e71c4a5e4e62f9f4857e2e9709c06214 100644 --- a/inventory_provider/routes/__init__.py +++ b/inventory_provider/routes/__init__.py @@ -36,4 +36,5 @@ and www.json.org for more details. .. automodule:: inventory_provider.routes.neteng +.. automodule:: inventory_provider.routes.map """ diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py index f0e28417e062be97855b859d86b07dbc4b2c1c12..e22c923f4df871f9264136fe8eed3af2a4be140f 100644 --- a/inventory_provider/routes/classifier.py +++ b/inventory_provider/routes/classifier.py @@ -1224,12 +1224,12 @@ def get_all_routers() -> Response: def _get_router_list(redis): - all_routers_raw = redis.get("netdash") + all_routers_raw = redis.get("gso:routers") all_routers = json.loads(all_routers_raw) if all_routers_raw else {} return [ - {"hostname": hostname, "vendor": vendor} - for hostname, vendor in all_routers.items() - if get_ims_equipment_name_or_none(hostname) + {"hostname": _r['fqdn'], "vendor": _r['vendor']} + for _r in all_routers + if get_ims_equipment_name_or_none(_r['fqdn']) ] @@ -1266,11 +1266,11 @@ def _get_router_info(ims_source_equipment, redis): contacts = set() vendor = "unknown" - all_routers = redis.get("netdash") + all_routers = redis.get("gso:routers") - for name, v in (json.loads(all_routers) if all_routers else {}).items(): - if name.startswith(ims_source_equipment.lower()): - vendor = v + for _r in (json.loads(all_routers) if all_routers else {}): + if _r['fqdn'].startswith(ims_source_equipment.lower()): + vendor = _r['vendor'] break for key in redis.scan_iter(f"ims:interface_services:{ims_source_equipment}:*"): diff --git a/inventory_provider/routes/map.py b/inventory_provider/routes/map.py new file mode 100644 index 0000000000000000000000000000000000000000..ad4a3821ee987c4218be72c2e0ccea43289f5d03 --- /dev/null +++ b/inventory_provider/routes/map.py @@ -0,0 +1,115 @@ +""" +mapping support endpoints +========================= + +These endpoints are intended for use by the mapping-provider for fetching +static inventory information relevant to maps. + +.. contents:: :local: + +/map/pops +--------------------------------- + +.. autofunction:: inventory_provider.routes.map.pops + + +/map/equipment +--------------------------------- + +.. autofunction:: inventory_provider.routes.map.equipment + + +""" +import json +import logging + +from flask import Blueprint, jsonify +import jsonschema + +from inventory_provider.routes import common + +logger = logging.getLogger(__name__) +routes = Blueprint('map-support-routes', __name__) + +EQUIPMENT_LIST_SCHEMA = { + '$schema': 'https://json-schema.org/draft/2020-12/schema', + + 'definitions': { + 'equipment': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'pop': {'type': 'string'}, + 'status': {'type': 'string'}, + }, + 'required': ['name', 'pop', 'status'], + 'additionalProperties': False + }, + }, + + 'type': 'array', + 'items': {'$ref': '#/definitions/equipment'} +} + +POP_LIST_SCHEMA = { + '$schema': 'https://json-schema.org/draft/2020-12/schema', + + 'definitions': { + 'pop': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'abbreviation': {'type': 'string'}, + 'city': {'type': 'string'}, + 'country': {'type': 'string'}, + 'latitude': {'type': ['number', 'null']}, + 'longitude': {'type': ['number', 'null']}, + }, + 'required': ['city', 'country', 'name', 'abbreviation', 'latitude', 'longitude'], + 'additionalProperties': False + }, + }, + + 'type': 'array', + 'items': {'$ref': '#/definitions/pop'} +} + + +@routes.route("/equipment", methods=['GET']) +@common.require_accepts_json +def equipment(service_type=None): + """ + Handler for `/map/equipment` + which returns information for all equipment in the IMS inventory + + .. asjson:: + inventory_provider.routes.map.EQUIPMENT_LIST_SCHEMA + + :return: list of equipment + """ + r = common.get_current_redis() + equipment_list = json.loads(r.get('ims:map:equipment').decode('utf-8')) + # ensure the internal data is formatted correctly + # TODO: handle validation gracefully? or just return an error? + jsonschema.validate(equipment_list, EQUIPMENT_LIST_SCHEMA) + return jsonify(equipment_list) + + +@routes.route("/pops", methods=['GET']) +@common.require_accepts_json +def pops(service_type=None): + """ + Handler for `/map/pops` + which returns information for all pops in the IMS inventory + + .. asjson:: + inventory_provider.routes.map.POP_LIST_SCHEMA + + :return: list of pops + """ + r = common.get_current_redis() + pop_list = json.loads(r.get('ims:map:pops').decode('utf-8')) + # ensure the internal data is formatted correctly + # TODO: handle validation gracefully? or just return an error? + jsonschema.validate(pop_list, POP_LIST_SCHEMA) + return jsonify(pop_list) diff --git a/inventory_provider/routes/msr.py b/inventory_provider/routes/msr.py index 9ccdb3409777c363a8d4d39c091b759390c97a59..2068656cdd9cb6c3e9dd7176c859f3533720b29a 100644 --- a/inventory_provider/routes/msr.py +++ b/inventory_provider/routes/msr.py @@ -108,7 +108,7 @@ import threading from collections import defaultdict from typing import Dict -from flask import Blueprint, Response, request, current_app +from flask import Blueprint, Response, request, current_app, jsonify import jsonschema from inventory_provider.routes import common @@ -1062,23 +1062,12 @@ def _endpoint_extractor(all_interfaces: Dict, endpoint_details: Dict): } -@routes.route('/services', methods=['GET']) -@common.require_accepts_json -def get_system_correlation_services(): +def load_all_msr_services(): """ - Handler for `/msr/services` - - This method returns all known services with with information required - by the reporting tool stack. - - cf. https://jira.software.geant.org/browse/POL1-530 - - The response will be formatted as follows: + utility method used to construct the response for /msr/services, + as well as other endpoints that construct similar responses - .. asjson:: - inventory_provider.routes.msr.SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA - - :return: + :return: List of all reportable services """ def _get_redundancy_asn(endpoints): @@ -1099,14 +1088,16 @@ def get_system_correlation_services(): cache_key = 'classifier-cache:msr:services' r = common.get_current_redis() - response = _ignore_cache_or_retrieve(request, cache_key, r) - if not response: + all_msr_services = _ignore_cache_or_retrieve(request, cache_key, r) + if all_msr_services: + all_msr_services = json.loads(all_msr_services) + else: all_interfaces = _load_all_interfaces() sid_services = json.loads(r.get('ims:sid_services').decode('utf-8')) - response = [] + all_msr_services = [] for sid, details in sid_services.items(): service_info = {'endpoints': []} for d in details: @@ -1128,14 +1119,39 @@ def get_system_correlation_services(): asn = _get_redundancy_asn(service_info['endpoints']) if asn: service_info['redundant_asn'] = asn - response.append(service_info) + all_msr_services.append(service_info) + # sanity jsonschema.validate( - response, SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA) + all_msr_services, SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA) + + if all_msr_services: + # expected to always be non-empty + r.set(cache_key, json.dumps(all_msr_services).encode('utf-8')) + + return all_msr_services - if response: - response = json.dumps(response, indent=2) - # r.set(cache_key, response.encode('utf-8')) + +@routes.route('/services', methods=['GET']) +@common.require_accepts_json +def get_system_correlation_services(): + """ + Handler for `/msr/services` + + This method returns all known services with with information required + by the reporting tool stack. + + cf. https://jira.software.geant.org/browse/POL1-530 + + The response will be formatted as follows: + + .. asjson:: + inventory_provider.routes.msr.SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA + + :return: + """ + + response = load_all_msr_services() if not response: return Response( @@ -1143,7 +1159,7 @@ def get_system_correlation_services(): status=404, mimetype="text/html") - return Response(response, mimetype="application/json") + return jsonify(response) @routes.route('/bgp', methods=['GET']) diff --git a/inventory_provider/routes/poller.py b/inventory_provider/routes/poller.py index 99d108f82db80757f7570cd8a7b52d4ec81b74bb..72e6292c8fb4cc5e57938b3820f8e411febbfaca 100644 --- a/inventory_provider/routes/poller.py +++ b/inventory_provider/routes/poller.py @@ -1019,20 +1019,17 @@ def interfaces(hostname=None): return Response(result, mimetype="application/json") -def get_netdash_equipment(config, use_next_redis=False) -> Dict[str, str]: - """Get the netdash equipment mapping from redis.""" +def vendor_getter(config, use_next_redis=False): if use_next_redis: r = tasks_common.get_next_redis(config) else: r = tasks_common.get_current_redis(config) - return json.loads(r.get('netdash').decode('utf-8')) - -def vendor_getter(config, use_next_redis=False): - netdash_equipment = get_netdash_equipment(config, use_next_redis) + managed_router_list = json.loads(r.get('gso:routers').decode('utf-8')) + managed_routers = {_r['fqdn']: _r['vendor'] for _r in managed_router_list} def get_vendor(router: str): - return netdash_equipment.get( + return managed_routers.get( router, "nokia" if router.startswith("rt0") else "juniper" ) diff --git a/inventory_provider/routes/testing.py b/inventory_provider/routes/testing.py index 5fcb7cce47a48eaa86ac4794d098448929ff69e4..5104e66038ce843bd600ebc96db90a4e6460010b 100644 --- a/inventory_provider/routes/testing.py +++ b/inventory_provider/routes/testing.py @@ -52,10 +52,10 @@ def get_circuit_hierarchy_and_port_id_services(): def juniper_addresses(): # TODO: this route (and corant, infinera routes) can be removed r = common.get_current_redis() - routers = r.get('netdash') + routers = r.get('gso:routers') assert routers # sanity: value shouldn't be empty routers = json.loads(routers.decode('utf-8')) - juniper_routers = [k for k, v in routers.items() if v == 'juniper'] + juniper_routers = [_r['fqdn'] for _r in routers if _r['vendor'] == 'juniper'] return jsonify(juniper_routers) diff --git a/inventory_provider/tasks/worker.py b/inventory_provider/tasks/worker.py index 516a88ae59db20fd2629033a288bb7e5eef2fed2..f1b66f176732034956233de1d04d460de0bf7068 100644 --- a/inventory_provider/tasks/worker.py +++ b/inventory_provider/tasks/worker.py @@ -6,6 +6,7 @@ import json import logging import os import re +from typing import Callable import ncclient.operations import ncclient.transport.errors @@ -468,42 +469,56 @@ def update_entry_point(self): update_latch_status(InventoryTask.config, pending=True) self.log_info("Starting update") - routers = retrieve_and_persist_neteng_managed_device_list( + retrieve_and_persist_gap_site_list( info_callback=self.log_info, - warning_callback=self.log_warning - ) + warning_callback=self.log_warning) + + routers = retrieve_and_persist_gap_managed_device_list( + info_callback=self.log_info, + warning_callback=self.log_warning) lab_routers = InventoryTask.config.get('lab-routers', {}) + + juniper_prod_fqdns = [r['fqdn'] for r in routers if r['vendor'] == 'juniper'] + self.log_info(f'juniper prod fqdns: {juniper_prod_fqdns}') + juniper_lab_fqdns = [r for r, v in lab_routers.items() if v == 'juniper'] + self.log_info(f'juniper lab fqdns: {juniper_lab_fqdns}') + nokia_prod_fqdns = [r['fqdn'] for r in routers if r['vendor'] == 'nokia'] + self.log_info(f'nokia prod fqdns: {nokia_prod_fqdns}') + nokia_lab_fqdns = [r for r, v in lab_routers.items() if v == 'nokia'] + self.log_info(f'nokia lab fqdns: {nokia_lab_fqdns}') + unknown_prod_fqdns = [r['fqdn'] for r in routers if r['vendor'] == 'unknown'] + if unknown_prod_fqdns: + self.log_info(f'unknown prod fqdns: {unknown_prod_fqdns}') + unknown_lab_fqdns = [r for r, v in lab_routers.items() if v == 'unknown'] + if unknown_lab_fqdns: + self.log_info(f'unknown lab fqdns: {unknown_lab_fqdns}') + chord( ( ims_task.s().on_error(task_error_handler.s()), + chord( - (reload_router_config_juniper.s(r) for r, v in routers.items() - if v == 'juniper'), + map(reload_router_config_juniper.s, juniper_prod_fqdns), empty_task.si('juniper router tasks complete') ), chord( - (reload_lab_router_config_juniper.s(r) - for r, v in lab_routers.items() if v == 'juniper'), + map(reload_router_config_juniper.s, juniper_lab_fqdns), empty_task.si('juniper lab router tasks complete') ), chord( - (reload_router_config_nokia.s(r) for r, v in routers.items() - if v == 'nokia'), + map(reload_router_config_nokia.s, nokia_prod_fqdns), empty_task.si('nokia router tasks complete') ), chord( - (reload_router_config_nokia.s(r, True) - for r, v in lab_routers.items() if v == 'nokia'), + map(reload_router_config_nokia.s, nokia_lab_fqdns), empty_task.si('nokia lab router tasks complete') ), chord( - (reload_router_config_try_all.s(r) for r, v in routers.items() - if v == 'unknown'), + map(reload_router_config_try_all.s, unknown_prod_fqdns), empty_task.si('unknown router tasks complete') ), chord( - (reload_router_config_try_all.s(r, True) - for r, v in lab_routers.items() if v == 'unknown'), + map(reload_router_config_try_all.s, unknown_lab_fqdns), empty_task.si('unknown lab router tasks complete') ) ), @@ -527,27 +542,44 @@ def empty_task(self, message): logger.warning(f'message from empty task: {message}') -def retrieve_and_persist_neteng_managed_device_list( - info_callback=lambda s: None, - warning_callback=lambda s: None): - netdash_equipment = None +def _retrieve_and_persist_value( + key: str, + retrieve_callback: Callable, + info_callback: Callable, + warning_callback: Callable, + info_keyword: str = 'data items'): + """ + utility method, not super general, but mostly will save a simple + result returned from `retrieve_callback` to the redis key `key` + + if `retrieve_callback` doesn't return anything, tries to get the + previous value + + :param key: redis key to save the result to + :param retrieve_callback: function to retrieve the data (takes app config dict) + :param info_callback: for logging progress callbacks + :param warning_callback: for logging warnings/errors + :param info_keyword: keyword to use in various info messages + :return: the data retrieved from `retrieve_callback` + """ + try: - info_callback('querying netdash for managed routers') - netdash_equipment = gap.load_routers_from_orchestrator(InventoryTask.config) + info_callback('querying for managed {info_keyword}s') + retreived_data = retrieve_callback(InventoryTask.config) except Exception as e: - warning_callback(f'Error retrieving device list: {e}') + warning_callback(f'Error retrieving {info_keyword} list: {e}') - if netdash_equipment: - info_callback(f'found {len(netdash_equipment)} routers') + if retreived_data: + info_callback(f'found {len(retreived_data)} {info_keyword}s') else: - warning_callback('No devices retrieved, using previous list') + warning_callback(f'No {info_keyword}s retrieved, using previous list') try: current_r = get_current_redis(InventoryTask.config) - netdash_equipment = current_r.get('netdash') - netdash_equipment = json.loads(netdash_equipment.decode('utf-8')) - if not netdash_equipment: + retreived_data = current_r.get('gso:routers') + retreived_data = json.loads(retreived_data.decode('utf-8')) + if not retreived_data: raise InventoryTaskError( - 'No equipment retrieved from previous list') + 'No {info_keyword}s retrieved from previous list') except Exception as e: warning_callback(str(e)) update_latch_status( @@ -556,13 +588,46 @@ def retrieve_and_persist_neteng_managed_device_list( try: next_r = get_next_redis(InventoryTask.config) - next_r.set('netdash', json.dumps(netdash_equipment)) - info_callback(f'saved {len(netdash_equipment)} managed routers') + next_r.set(key, json.dumps(retreived_data)) + info_callback(f'saved {len(retreived_data)} managed {info_keyword}s') except Exception as e: warning_callback(str(e)) update_latch_status(InventoryTask.config, pending=False, failure=True) raise e - return netdash_equipment + + return retreived_data + + +def retrieve_and_persist_gap_managed_device_list( + info_callback=lambda s: None, + warning_callback=lambda s: None): + """ + loads the list of managed routers from GSO, saves + in gso:routers redis key + """ + + return _retrieve_and_persist_value( + key='gso:routers', + retrieve_callback=gap.load_routers_from_orchestrator, + info_callback=info_callback, + warning_callback=warning_callback, + info_keyword='router') + + +def retrieve_and_persist_gap_site_list( + info_callback=lambda s: None, + warning_callback=lambda s: None): + """ + loads the list of sites from GSO, saves + in gso:sites redis key + """ + + return _retrieve_and_persist_value( + key='gso:sites', + retrieve_callback=gap.load_sites_from_orchestrator, + info_callback=info_callback, + warning_callback=warning_callback, + info_keyword='site') @app.task(base=InventoryTask, bind=True, name='reload_router_config_try_all') @@ -1743,6 +1808,23 @@ def persist_ims_data(data, use_current=False): return sites.values() + def _get_map_equipment(): + # simple list of equipment from locations + def _fmt_elem(_x): + return { + 'name': _x['equipment-name'], + 'status': _x['status'], + 'pop': _x['pop']['name'] + } + return list(map(_fmt_elem, locations.values())) + + def _get_map_pops(): + # get a de-duped list of sites with actual equipment + _pops = {} + for _x in locations.values(): + _pops[_x['pop']['name']] = _x['pop'] + return list(_pops.values()) + if use_current: r = get_current_redis(InventoryTask.config) @@ -1758,7 +1840,8 @@ def persist_ims_data(data, use_current=False): 'ims:access_services:*', 'ims:gws_indirect:*', 'ims:node_pair_services:*', - 'ims:pop_nodes:*' + 'ims:pop_nodes:*', + 'ims:map:*', ]: rp = r.pipeline() for k in r.scan_iter(key_pattern, count=1000): @@ -1766,7 +1849,10 @@ def persist_ims_data(data, use_current=False): else: r = get_next_redis(InventoryTask.config) + r.set('ims:map:pops', json.dumps(_get_map_pops())) + r.set('ims:map:equipment', json.dumps(_get_map_equipment())) r.set('ims:sid_services', json.dumps(sid_services)) + rp = r.pipeline() for h, d in locations.items(): rp.set(f'ims:location:{h}', json.dumps([d])) @@ -2046,11 +2132,11 @@ def _populate_equipment_vendors(): def populate_equipment_vendors(r): equipment_details = json.loads(r.get('ims:cache:equipment_details').decode('utf-8')) - router_vendors = json.loads(r.get('netdash').decode('utf-8')) + routers = json.loads(r.get('gso:routers').decode('utf-8')) equipment_details.extend({ - 'name': r, + 'name': r['fqdn'], 'model': 'UNKNOWN', - 'vendor': v} for r, v in router_vendors.items()) + 'vendor': r['vendor']} for r in routers) for ed in equipment_details: r.set(f'state-checker:equipment-vendors:{ed["name"]}', json.dumps(ed)) diff --git a/setup.py b/setup.py index e50a345ba133ec0043f54b61c542ee2fbab96898..d99f1e76be04064f12c3888f48cf5e8fdb25a9ac 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name='inventory-provider', - version="0.145", + version="0.146", author='GEANT', author_email='swd@geant.org', description='Dashboard inventory provider', diff --git a/test/data/router-info.json b/test/data/router-info.json index a46964473af6b4a5af9f8210b18eac9f62f1dace..51ac638c56fe834130b414f1cd8cfa5f16dd2b6b 100644 Binary files a/test/data/router-info.json and b/test/data/router-info.json differ diff --git a/test/test_classifier_routes.py b/test/test_classifier_routes.py index e4a5b8a73dfc54e6f6870da43777f6a8c25269c4..e5cca53d2aaab00f3b54f656c0ef243709a65364 100644 --- a/test/test_classifier_routes.py +++ b/test/test_classifier_routes.py @@ -238,9 +238,13 @@ def test_router_info_all_routers(client): def test_all_routers_skips_routers_not_in_ims(client, mocked_redis): - all_routers = json.loads(mocked_redis.get('netdash')) - all_routers['invalid.router'] = 'juniper' - mocked_redis.set('netdash', json.dumps(all_routers)) + all_routers = json.loads(mocked_redis.get('gso:routers')) + all_routers.append({ + 'fqdn': 'invalid.router', + 'vendor': 'juniper', + 'site': 'AAA' + }) + mocked_redis.set('gso:routers', json.dumps(all_routers)) result = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS).json assert not any(r['hostname'] == 'invalid.router' for r in result) diff --git a/test/test_gap.py b/test/test_gap.py index ea8d0503f77ecdea91a5bbe991c6c96f64269627..dca58e73efe6941b49dc89f1eaf1c02411b15fb6 100644 --- a/test/test_gap.py +++ b/test/test_gap.py @@ -40,8 +40,11 @@ def test_extract_router_info(mocker): 'page': [{ 'productBlockInstances': [{ 'productBlockInstanceValues': [ + {'field': 'name', 'value': 'block name'}, + {'field': 'label', 'value': 'ignored label'}, {'field': 'routerFqdn', 'value': 'test_fqdn'}, - {'field': 'vendor', 'value': 'test_vendor'} + {'field': 'vendor', 'value': 'test_vendor'}, + {'field': 'siteName', 'value': 'test_site'} ] }] }] @@ -61,7 +64,11 @@ def test_extract_router_info(mocker): } } router_info = gap.extract_router_info(device, 'test_token', config) - assert router_info == {'fqdn': 'test_fqdn', 'vendor': 'test_vendor'} + assert router_info == { + 'fqdn': 'test_fqdn', + 'vendor': 'test_vendor', + 'site': 'test_site' + } def test_load_routers_from_orchestrator(mocker): @@ -83,8 +90,8 @@ def test_load_routers_from_orchestrator(mocker): } }) mocker.patch('inventory_provider.gap.extract_router_info', side_effect=[ - {'fqdn': 'fqdn1', 'vendor': 'vendor1'}, - {'fqdn': 'fqdn2', 'vendor': 'vendor2'} + {'fqdn': 'fqdn1', 'vendor': 'vendor1', 'site': 'AAA'}, + {'fqdn': 'fqdn2', 'vendor': 'vendor2', 'site': 'BBB'} ]) mocker.patch('inventory_provider.gap.get_token', return_value='test_token') config = { @@ -101,4 +108,7 @@ def test_load_routers_from_orchestrator(mocker): } routers = gap.load_routers_from_orchestrator(config) - assert routers == {'fqdn1': 'vendor1', 'fqdn2': 'vendor2'} + assert routers == [ + {'fqdn': 'fqdn1', 'vendor': 'vendor1', 'site': 'AAA'}, + {'fqdn': 'fqdn2', 'vendor': 'vendor2', 'site': 'BBB'} + ] diff --git a/test/test_map_routes.py b/test/test_map_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..cfc8237d5a018a0f52d75af435e53b0c94848be9 --- /dev/null +++ b/test/test_map_routes.py @@ -0,0 +1,28 @@ +import json +import jsonschema + +from inventory_provider.routes import map + + +def test_get_equipment(client): + rv = client.get( + '/map/equipment', + headers={'Accept': ['application/json']}) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, map.EQUIPMENT_LIST_SCHEMA) + assert response_data # test data is non-empty + # print(json.dumps(response_data, indent=2)) + + +def test_get_pops(client): + rv = client.get( + '/map/pops', + headers={'Accept': ['application/json']}) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, map.POP_LIST_SCHEMA) + assert response_data # test data is non-empty + # print(json.dumps(response_data, indent=2)) diff --git a/test/test_worker.py b/test/test_worker.py index 541c508ba1d06aec7450b9cf6dbd995f2f0c967a..aea808b45ff65f6eb81e5dd7cd5d09fc5fb658a3 100644 --- a/test/test_worker.py +++ b/test/test_worker.py @@ -8,7 +8,8 @@ from inventory_provider.tasks.worker import ( transform_ims_data, extract_ims_data, persist_ims_data, - retrieve_and_persist_neteng_managed_device_list, + retrieve_and_persist_gap_managed_device_list, + retrieve_and_persist_gap_site_list, populate_poller_interfaces_cache, refresh_nokia_interface_list, retrieve_and_persist_config_nokia, @@ -211,6 +212,7 @@ def test_transform_ims_data(): locations = { "eq_a": { "equipment-name": "eq_a", + "status": "operational", "pop": { "name": "pop_loc_a", "abbreviation": "pla", @@ -218,6 +220,7 @@ def test_transform_ims_data(): }, "eq_b": { "equipment-name": "eq_b", + "status": "bogus", "pop": { "name": "pop_loc_b", "abbreviation": "plb", @@ -225,6 +228,7 @@ def test_transform_ims_data(): }, "UNKNOWN_LOC": { "equipment-name": "UNKNOWN_LOC", + "status": "blah blah", "pop": { "name": "UNKNOWN", "abbreviation": "UNKNOWN", @@ -575,10 +579,12 @@ def test_persist_ims_data(mocker, data_config, mocked_redis): "locations": { "eq_a": { 'equipment-name': 'eq_a', + 'status': 'operational', 'pop': {'name': "LOC A", 'abbreviation': 'aaa'} }, "eq_b": { 'equipment-name': 'eq_b', + 'status': 'blah blah', 'pop': {'name': "LOC B", 'abbreviation': 'bbb'} }, }, @@ -674,21 +680,61 @@ def test_persist_ims_data(mocker, data_config, mocked_redis): data["sid_services"] -def test_retrieve_and_persist_neteng_managed_device_list( +def test_retrieve_and_persist_gap_managed_device_list( mocker, data_config, mocked_redis): - device_list = [{'abc': 'juniper'}, {'def': 'nokia'}] + + device_list = [ + {'fqdn': 'hostname1', 'site': 'AAA', 'vendor': 'juniper'}, + {'fqdn': 'hostname2', 'site': 'BBB', 'vendor': 'nokia'}, + ] r = common._get_redis(data_config) mocker.patch( 'inventory_provider.tasks.worker.InventoryTask.config' ) mocker.patch('inventory_provider.tasks.worker.get_next_redis', return_value=r) - r.delete('netdash') + r.delete('gso:routers') mocker.patch('inventory_provider.tasks.worker.get_current_redis', return_value=r) mocker.patch('inventory_provider.gap.load_routers_from_orchestrator', return_value=device_list) - result = retrieve_and_persist_neteng_managed_device_list() + + result = retrieve_and_persist_gap_managed_device_list() assert result == device_list - assert json.loads(r.get('netdash')) == device_list + assert json.loads(r.get('gso:routers')) == device_list + + +def test_retrieve_and_persist_gap_site_list( + mocker, data_config, mocked_redis): + site_list = [ + { + 'city': 'city#1', + 'country': 'country#1', + 'country_code': 'AA', + 'name': 'AAA', + 'longitude': 0.0, + 'latitude': 1.1, + 'tier': 3 + }, + { + 'city': 'city #2', + 'country': 'country #2', + 'country_code': 'ZZ', + 'name': 'ZZZ', + 'longitude': 99.89, + 'latitude': 123.456, + 'tier': 1 + }, + ] + + r = common._get_redis(data_config) + mocker.patch('inventory_provider.tasks.common._get_redis', return_value=r) + + mocker.patch('inventory_provider.tasks.worker.InventoryTask.config') + r.delete('gso:sites') + mocker.patch('inventory_provider.gap.load_sites_from_orchestrator', return_value=site_list) + + result = retrieve_and_persist_gap_site_list() + assert result == site_list + assert json.loads(r.get('gso:sites')) == site_list def test_populate_poller_interfaces_cache( @@ -1010,8 +1056,8 @@ def test_refresh_nokia_interface_list(mocked_redis, data_config, load_nokia_netc def test_populate_error_report_interfaces_cache(mocker, data_config, mocked_redis): r = common._get_redis(data_config) + mocker.patch('inventory_provider.tasks.common._get_redis', return_value=r) - mocker.patch('inventory_provider.tasks.worker.get_next_redis', return_value=r) all_interfaces = [ { "router": "router_a.geant.net", @@ -1069,12 +1115,21 @@ def test_populate_error_report_interfaces_cache(mocker, data_config, mocked_redi 'inventory_provider.tasks.worker.InventoryTask.config' ) - netdash_equipment = { - "router_a.geant.net": "juniper", - "rt0.geant.net": "nokia" - } + test_routers = [ + { + 'fqdn': 'router_a.geant.net', + 'vendor': 'juniper', + 'site': 'AAA' + }, + { + 'fqdn': 'rt0.geant.net', + 'vendor': 'nokia', + 'site': 'BBB' + } + ] + + mocked_redis.set('gso:routers', json.dumps(test_routers)) - mocker.patch('inventory_provider.routes.poller.get_netdash_equipment', return_value=netdash_equipment) exp_router_a_interfaces = [ { "router": "router_a.geant.net",