diff --git a/Changelog.md b/Changelog.md index 00dbccbc6e5fd395636f5506fd3475d920c92050..0b78aed3f2a96828a31e568adabf0f8c51dcfafb 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. +## [0.92] - 2022-08-12 +- REPORTING-312: Added NREN asn's to /msr/services response + ## [0.91] - 2022-08-03 - REPORTING-311: Added /msr/asn-peers endpoint diff --git a/inventory_provider/routes/msr.py b/inventory_provider/routes/msr.py index 8c052a67f30d3f540375a2666c9b5ffd460fb3d9..a1828a2082edb8f23454ddb32a72f7ccd564eac1 100644 --- a/inventory_provider/routes/msr.py +++ b/inventory_provider/routes/msr.py @@ -260,7 +260,8 @@ SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA = { 'service_type': {'type': 'string'}, # TODO: enum? 'project': {'type': 'string'}, # TODO: remove this? 'customer': {'type': 'string'}, - 'endpoints': {'$ref': '#/definitions/endpoints'} + 'endpoints': {'$ref': '#/definitions/endpoints'}, + 'redundant_asn': {'type': 'integer'} }, 'required': [ 'circuit_id', 'sid', 'name', 'speed', 'status', 'monitored', @@ -881,6 +882,129 @@ def get_peering_services(): return Response(response, mimetype="application/json") +# TODO: @functools.cache is only available in py3.9 +@functools.lru_cache(maxsize=None) +def _load_all_interfaces(): + """ + loads all ip interfaces in the network and returns as a dict + of dicts: + hostname -> interface name -> interface info + + :return: dict of dicts + """ + # dict of dicts: + # peering_info[hostname][interface_name] = dict of ifc details + result = defaultdict(dict) + + host_if_extraction_re = re.compile(r'^netconf-interfaces:(.+?):') + for doc in common.load_json_docs( + config_params=current_app.config['INVENTORY_PROVIDER_CONFIG'], + key_pattern='netconf-interfaces:*', + num_threads=20): + matches = host_if_extraction_re.match(doc['key']) + if matches: + hostname = matches[1] + interface_name = doc['value']['name'] + result[hostname][interface_name] = doc['value'] + + return result + + +# TODO: @functools.cache is only available in py3.9 +@functools.lru_cache(maxsize=None) +def _load_redundant_access_peers(): + """ + load all peers that should be considered + redundant for access services + + that is, all peers for services like NREN-APx + + :return: dict of [peer address] -> [remote asn] + """ + r = common.get_current_redis() + result = {} + + # cf. REPORTING-312: limited to eGEANT group, + # but this can be expanded here in future + redundant_access_groups = ['eGEANT'] + for g in redundant_access_groups: + doc = r.get(f'juniper-peerings:group:{g}') + for peer in json.loads(doc.decode('utf-8')): + result[peer['address']] = peer['remote-asn'] + + return result + + +def _ip_endpoint_extractor(endpoint_details: dict): + """ + special-purpose method used only by _endpoint_extractor + + operates on a dictionary formatted as in worker.transform_ims_data + (cf sid_services) + + WARNING: assumes hostname is a router in _load_all_interfaces, + asserts if not + + :param endpoint_details: dict formatted as in worker.transform_ims_data + :return: dict formatted + as SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA.ip-endpoint + """ + hostname = ims_equipment_to_hostname(endpoint_details['equipment']) + interface = endpoint_details['port'].lower() + + ip_endpoint = { + 'hostname': hostname, + 'interface': interface, + } + + all_interfaces = _load_all_interfaces() + # sanity: should have already been checked + assert hostname in all_interfaces + + host_info = all_interfaces[hostname] + interface_info = host_info.get(interface, {}) + + addresses = {} + ipv4 = interface_info.get('ipv4') + ipv6 = interface_info.get('ipv6') + if ipv4: + addresses['v4'] = ipv4[0] + if ipv6: + addresses['v6'] = ipv6[0] + if addresses: + ip_endpoint['addresses'] = addresses + + return ip_endpoint + + +def _endpoint_extractor(endpoint_details: Dict): + """ + special-purpose method used only by get_system_correlation_services + + operates on a dictionary formatted as in worker.transform_ims_data + (cf sid_services) + + WARNING: assumes hostname is a router in _load_all_interfaces, + asserts if not + + :param endpoint_details: dict formatted as in worker.transform_ims_data + :return: dict formatted as one element of the array + SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA.endpoints + """ + if not endpoint_details['geant_equipment']: + return + potential_hostname = ims_equipment_to_hostname( + endpoint_details['equipment']) + all_routers = _load_all_interfaces().keys() + if potential_hostname in all_routers: + return _ip_endpoint_extractor(endpoint_details) + else: + return { + 'equipment': endpoint_details['equipment'], + 'port': endpoint_details['port'] + } + + @routes.route('/services', methods=['GET', 'POST']) @common.require_accepts_json def get_system_correlation_services(): @@ -900,63 +1024,26 @@ def get_system_correlation_services(): :return: """ + def _get_redundancy_asn(endpoints): + # endpoints should be a list formatted as + # SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA.endpoints + + redundant_peerings = _load_redundant_access_peers() + for ep in endpoints: + addresses = ep.get('addresses', {}) + for ifc_address in addresses.values(): + for p, asn in redundant_peerings.items(): + peer = ipaddress.ip_address(p) + ifc = ipaddress.ip_interface(ifc_address) + if peer in ifc.network: + return asn + return None + cache_key = 'classifier-cache:msr:services' r = common.get_current_redis() response = _ignore_cache_or_retrieve(request, cache_key, r) if not response: - peering_info = defaultdict(defaultdict) - - key_pattern = 'netconf-interfaces:*' - - host_if_extraction_re = re.compile( - r'^netconf-interfaces:(.+?):') - for doc in common.load_json_docs( - config_params=current_app.config['INVENTORY_PROVIDER_CONFIG'], - key_pattern=key_pattern, - num_threads=20): - matches = host_if_extraction_re.match(doc['key']) - if matches: - peering_info[matches[1]][doc['value']['name']] = doc['value'] - - def _ip_endpoint_extractor(endpoint_details: Dict): - hostname = ims_equipment_to_hostname( - endpoint_details['equipment']) - interface = endpoint_details['port'].lower() - - ip_endpoint = { - 'hostname': hostname, - 'interface': interface, - } - addresses = {} - host_info = peering_info.get(hostname, {}) - interface_info = host_info.get(interface, {}) - ipv4 = interface_info.get('ipv4') - ipv6 = interface_info.get('ipv6') - if ipv4: - addresses['v4'] = ipv4[0] - if ipv6: - addresses['v6'] = ipv6[0] - if ipv4 or ipv6: - ip_endpoint['addresses'] = addresses - - return ip_endpoint - - def _optical_endpoint_extractor(endpoint_details: Dict): - return { - 'equipment': endpoint_details['equipment'], - 'port': endpoint_details['port'] - } - - def _endpoint_extractor(endpoint_details: Dict): - if not endpoint_details['geant_equipment']: - return - potential_hostname = ims_equipment_to_hostname( - endpoint_details['equipment']) - if potential_hostname in peering_info.keys(): - return _ip_endpoint_extractor(endpoint_details) - else: - return _optical_endpoint_extractor(endpoint_details) sid_services = json.loads(r.get('ims:sid_services').decode('utf-8')) @@ -977,15 +1064,19 @@ def get_system_correlation_services(): endpoint = _endpoint_extractor(d) if endpoint: service_info['endpoints'].append(endpoint) + if service_info.get('endpoints'): + asn = _get_redundancy_asn(service_info['endpoints']) + if asn: + service_info['redundant_asn'] = asn response.append(service_info) - jsonschema.validate(response, - SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA) + jsonschema.validate( + response, SYSTEM_CORRELATION_SERVICES_LIST_SCHEMA) if response: response = json.dumps(response, indent=2) - r.set(cache_key, response.encode('utf-8')) + # r.set(cache_key, response.encode('utf-8')) if not response: return Response( @@ -1225,10 +1316,10 @@ def asn_peers(asn): This method returns a list of all peers filtered by `group` and `instance`, which can be passed either as URL query parameters or as entries in a POST request with a JSON body that matches this schema: - `{ - "group": "group to filter by", - "instance": "instance to filter by" - }` + `{ + "group": "group to filter by", + "instance": "instance to filter by" + }` Results are returned where all filters given are true, and exact string matches. diff --git a/inventory_provider/tasks/worker.py b/inventory_provider/tasks/worker.py index 3517bb0377fa4b6acf5e1310223de180fd01bba6..dda87815227f1f467aa6d0932f9ab3a863f2d980 100644 --- a/inventory_provider/tasks/worker.py +++ b/inventory_provider/tasks/worker.py @@ -722,11 +722,24 @@ def ims_task(self, use_current=False): def extract_ims_data(): - c = InventoryTask.config["ims"] + return _extract_ims_data( + ims_api_url=c['api'], + ims_username=c['username'], + ims_password=c['password']) + + +def _extract_ims_data(ims_api_url, ims_username, ims_password): + """ + convenient entry point for testing ... + :param ims_api_url: + :param ims_username: + :param ims_password: + :return: + """ def _ds() -> IMS: - return IMS(c['api'], c['username'], c['password']) + return IMS(ims_api_url, ims_username, ims_password) locations = {} lg_routers = [] diff --git a/setup.py b/setup.py index 4da31e2bd8816da258d745c106d778803380c1a4..7334603bb525aa4215e804b13b20b74e509bff2b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name='inventory-provider', - version="0.91", + version="0.92", author='GEANT', author_email='swd@geant.org', description='Dashboard inventory provider',