diff --git a/Changelog.md b/Changelog.md index 241783dd118098419ef63f16cac824ebae0f5272..5e1473f11bcd48d66948d2ff076f733a7490b762 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.67] - 2021-06-24 +- DBOARD3-448: pulled additional customers from CircuitCustomerRelation +- DBOARD3-449: add CORS headers to responses +- POL1-452: added /poller/gws/indirect +- POL1-453: config should contain 'CenturyLink' and not 'Century Link' + ## [0.66] - 2021-06-09 - POL1-445: added /poller/gws/direct endpoint - DBOARD3-445: bugfixes in /lg/routers/X endpoint diff --git a/inventory_provider/__init__.py b/inventory_provider/__init__.py index aa92cb410dffe41600160d1d94bd1b98e3f9a75d..877944d598d7f3731b2022174a03f7424eadcb1c 100644 --- a/inventory_provider/__init__.py +++ b/inventory_provider/__init__.py @@ -4,6 +4,7 @@ automatically invoked app factory import logging import os from flask import Flask +from flask_cors import CORS from inventory_provider import environment @@ -34,6 +35,8 @@ def create_app(): inventory_provider_config = config.load(f) app = Flask(__name__) + CORS(app) + app.secret_key = 'super secret session key' logging.info( diff --git a/inventory_provider/config.py b/inventory_provider/config.py index 4881d4c10921fae338a29cf71b8638e48add315d..733877cc12a078b0579ae783dc9585d490471e63 100644 --- a/inventory_provider/config.py +++ b/inventory_provider/config.py @@ -137,7 +137,7 @@ CONFIG_SCHEMA = { 'nren': {'type': 'string'}, 'isp': { 'type': 'string', - 'enum': ['Cogent', 'Telia', 'Century Link'] + 'enum': ['Cogent', 'Telia', 'CenturyLink'] }, 'hosts': { 'type': 'array', diff --git a/inventory_provider/db/ims_data.py b/inventory_provider/db/ims_data.py index f6d856c845b328aa7a0cf65a7afa7af10adad29d..0283754bf0f3367fdb6a1bf9911dcf3ecb88970d 100644 --- a/inventory_provider/db/ims_data.py +++ b/inventory_provider/db/ims_data.py @@ -2,7 +2,8 @@ import logging import re from collections import OrderedDict, defaultdict from copy import copy -from itertools import chain +from itertools import chain, groupby +from operator import itemgetter from inventory_provider import environment from inventory_provider.db import ims @@ -75,6 +76,18 @@ def get_customer_service_emails(ds: IMS): yield k, sorted(list(v)) +def get_circuit_related_customer_ids(ds: IMS): + relations = sorted( + list( + ds.get_filtered_entities( + 'CircuitCustomerRelation', + 'circuit.inventoryStatusId== 3') + ), key=itemgetter('circuitid')) + + return {k: [c['customerid'] for c in v] for k, v in + groupby(relations, key=itemgetter('circuitid'))} + + def get_port_id_services(ds: IMS): circuit_nav_props = [ ims.CIRCUIT_PROPERTIES['Ports'], diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py index 6d27c8a081c398aec98ade4aac42647ef0dd00fe..d2b46d342a1793562e52aa5cd04ca265c88a480c 100644 --- a/inventory_provider/routes/classifier.py +++ b/inventory_provider/routes/classifier.py @@ -185,12 +185,12 @@ def get_interface_services_and_loc(ims_source_equipment, ims_interface, redis): for s in json.loads(raw_services.decode('utf-8')): related_services.update( {r['id']: r for r in s['related-services']}) + contacts.update(set(s.pop('contacts', set()))) if s['circuit_type'] == 'service': - contacts.update(set(s.pop('contacts', set()))) _format_service(s) result['services'].append(s) - result['related-services'] = list(related_services.values()) result['contacts'] = sorted(list(contacts)) + result['related-services'] = list(related_services.values()) if not result['services']: result.pop('services', None) diff --git a/inventory_provider/routes/common.py b/inventory_provider/routes/common.py index 56ed526edd5f5fa87a6b0c37947f73bf36fb8f73..8213ad6ba18a635f3f17c3324f4d29e3d955234c 100644 --- a/inventory_provider/routes/common.py +++ b/inventory_provider/routes/common.py @@ -4,6 +4,7 @@ import json import logging import queue import random +import re import threading from distutils.util import strtobool @@ -17,13 +18,18 @@ _DECODE_TYPE_XML = 'xml' _DECODE_TYPE_JSON = 'json' -def _ignore_cache_or_retrieve(request_, cache_key, r): - - ignore_cache = request_.args.get('ignore-cache', default='false', type=str) +def get_bool_request_arg(name, default=False): + assert isinstance(default, bool) # sanity, otherwise caller error + value = request.args.get(name, default=str(default), type=str) try: - ignore_cache = strtobool(ignore_cache) + value = strtobool(value) except ValueError: - ignore_cache = False + value = default + return value + + +def _ignore_cache_or_retrieve(request_, cache_key, r): + ignore_cache = get_bool_request_arg('ignore-cache', default=False) if ignore_cache: result = False logger.debug('ignoring cache') @@ -254,6 +260,7 @@ def load_xml_docs(config_params, key_pattern, num_threads=10): config_params, key_pattern, num_threads, doc_type=_DECODE_TYPE_XML) +@functools.lru_cache(maxsize=None) def load_snmp_indexes(hostname=None): result = dict() key_pattern = f'snmp-interfaces:{hostname}*' \ @@ -266,3 +273,21 @@ def load_snmp_indexes(hostname=None): result[router] = {e['name']: e for e in doc['value']} return result + + +def ims_equipment_to_hostname(equipment): + """ + changes names like MX1.AMS.NL to mx1.ams.nl.geant.net + + leaves CPE names alone (e.g. 'INTERXION Z-END') + :param equipment: the IMS equipment name string + :return: hostname, or the input string if it doesn't look like a host + """ + if re.match(r'.*\s.*', equipment): + # doesn't look like a hostname + return equipment + + hostname = equipment.lower() + if not re.match(r'.*\.geant\.(net|org)$', hostname): + hostname = f'{hostname}.geant.net' + return hostname diff --git a/inventory_provider/routes/jobs.py b/inventory_provider/routes/jobs.py index 078e8a56392bd5bb4bb4076d83b6ced585f89e97..b33476fb1d031b4133585eb204c2ca587b466b59 100644 --- a/inventory_provider/routes/jobs.py +++ b/inventory_provider/routes/jobs.py @@ -28,8 +28,7 @@ These endpoints are used for monitoring running jobs. import json import logging -from distutils.util import strtobool -from flask import Blueprint, current_app, jsonify, Response, request +from flask import Blueprint, current_app, jsonify, Response from inventory_provider.tasks import monitor from inventory_provider.tasks import worker @@ -125,15 +124,10 @@ def update(): :return: """ - force = request.args.get('force', default='false', type=str) - try: - force = strtobool(force) - except ValueError: - force = False - config = current_app.config['INVENTORY_PROVIDER_CONFIG'] r = get_current_redis(config) + force = common.get_bool_request_arg('force', default=False) if not force: latch = get_latch(r) if latch and latch['pending']: diff --git a/inventory_provider/routes/lg.py b/inventory_provider/routes/lg.py index 21360110371073f056a65a440facc094567cb444..e8231e86bdf04f558d662278692bc4790ddad59d 100644 --- a/inventory_provider/routes/lg.py +++ b/inventory_provider/routes/lg.py @@ -12,7 +12,6 @@ These endpoints are intended for use by LG. """ import json import logging -import re from flask import Blueprint, Response, request @@ -119,14 +118,12 @@ def routers(access): for k in redis.scan_iter('ims:lg:*', count=1000): rtr = redis.get(k.decode('utf-8')).decode('utf-8') rtr = json.loads(rtr) - hostname = rtr['equipment name'].lower() - if ' ' in hostname: + if ' ' in rtr['equipment name']: logger.warning( 'skipping LG router with ws in hostname: {hostname}') continue - if not re.match(r'.*\.geant\.(net|org)$', hostname): - hostname = f'{hostname}.geant.net' - rtr['equipment name'] = hostname + rtr['equipment name'] = common.ims_equipment_to_hostname( + rtr['equipment name']) yield rtr cache_key = f'classifier-cache:ims-lg:{access}' diff --git a/inventory_provider/routes/msr.py b/inventory_provider/routes/msr.py index fa944be7e8bc7af567e411df0829c8e0abe98150..0bb6a65c68f23bf45ceac35c979f391c85ef563d 100644 --- a/inventory_provider/routes/msr.py +++ b/inventory_provider/routes/msr.py @@ -4,12 +4,13 @@ MSR Support Endpoints These endpoints are intended for use by MSR. + .. contents:: :local: -/msr/access-services +/poller/access-services --------------------------------- -.. autofunction:: inventory_provider.routes.msr.access_services +.. autofunction::inventory_provider.routes.msr.get_access_services /msr/bgp/logical-systems @@ -62,75 +63,46 @@ from flask import Blueprint, Response, request from inventory_provider.routes import common from inventory_provider.routes.common import _ignore_cache_or_retrieve +from inventory_provider.routes.poller import get_services -routes = Blueprint("msr-query-routes", __name__) - +routes = Blueprint('msr-query-routes', __name__) -ACCESS_SERVICES_LIST_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - - "definitions": { - "service": { - "type": "object", - "properties": { - "id": {"type": "integer"}, - "name": {"type": "string"}, - "equipment": {"type": "string"}, - "pop_name": {"type": "string"}, - "other_end_equipment": {"type": "string"}, - "other_end_pop_name": {"type": "string"}, - "speed_value": {"type": "integer"}, - "speed_unit": {"type": "string"} - }, - "required": [ - "id", "name", - "pop_name", "equipment", - "other_end_pop_name", "other_end_equipment", - "speed_value", "speed_unit" - ], - "additionalProperties": False - } - }, - - "type": "array", - "items": {"$ref": "#/definitions/service"} -} PEERING_GROUP_LIST_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "array", - "items": {"type": "string"} + '$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'array', + 'items': {'type': 'string'} } PEERING_LIST_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "peering-instance": { - "type": "object", - "properties": { - "address": {"type": "string"}, - "description": {"type": "string"}, - "logical-system": {"type": "string"}, - "group": {"type": "string"}, - "hostname": {"type": "string"}, - "remote-asn": {"type": "integer"}, - "local-asn": {"type": "integer"}, - "instance": {"type": "string"} + '$schema': 'http://json-schema.org/draft-07/schema#', + 'definitions': { + 'peering-instance': { + 'type': 'object', + 'properties': { + 'address': {'type': 'string'}, + 'description': {'type': 'string'}, + 'logical-system': {'type': 'string'}, + 'group': {'type': 'string'}, + 'hostname': {'type': 'string'}, + 'remote-asn': {'type': 'integer'}, + 'local-asn': {'type': 'integer'}, + 'instance': {'type': 'string'} }, # only vrr peerings have remote-asn # only group peerings have local-asn or instance # not all group peerings have 'description' # and only vrr or vpn-proxy peerings are within a logical system - "required": [ - "address", - "group", - "hostname"], - "additionalProperties": False + 'required': [ + 'address', + 'group', + 'hostname'], + 'additionalProperties': False } }, - "type": "array", - "items": {"$ref": "#/definitions/peering-instance"} + 'type': 'array', + 'items': {'$ref': '#/definitions/peering-instance'} } @@ -139,48 +111,6 @@ def after_request(resp): return common.after_request(resp) -@routes.route("/access-services", methods=['GET', 'POST']) -@common.require_accepts_json -def access_services(): - """ - Handler for `/msr/access-services`. - - This method is in development, not yet used. - - The response will be formatted according to the following schema: - - .. asjson:: - inventory_provider.routes.msr.ACCESS_SERVICES_LIST_SCHEMA - - :return: - """ - redis = common.get_current_redis() - - def _services(): - for k in redis.scan_iter('ims:access_services:*'): - service = redis.get(k.decode('utf-8')).decode('utf-8') - yield json.loads(service) - - cache_key = 'classifier-cache:msr:access-services' - - result = _ignore_cache_or_retrieve(request, cache_key, redis) - - if not result: - result = list(_services()) - - if not result: - return Response( - response='no access services found', - status=404, - mimetype="text/html") - - # cache this data for the next call - result = json.dumps(result) - redis.set(cache_key, result.encode('utf-8')) - - return Response(result, mimetype="application/json") - - def _handle_peering_group_request(name, cache_key, group_key_base): """ Common method for used by @@ -239,8 +169,8 @@ def _handle_peering_group_request(name, cache_key, group_key_base): return Response(items, mimetype="application/json") -@routes.route("/bgp/logical-system-peerings", methods=['GET', 'POST']) -@routes.route("/bgp/logical-system-peerings/<name>", methods=['GET', 'POST']) +@routes.route('/bgp/logical-system-peerings', methods=['GET', 'POST']) +@routes.route('/bgp/logical-system-peerings/<name>', methods=['GET', 'POST']) @common.require_accepts_json def logical_system_peerings(name=None): """ @@ -258,8 +188,8 @@ def logical_system_peerings(name=None): group_key_base='juniper-peerings:logical-system') -@routes.route("/bgp/group-peerings", methods=['GET', 'POST']) -@routes.route("/bgp/group-peerings/<name>", methods=['GET', 'POST']) +@routes.route('/bgp/group-peerings', methods=['GET', 'POST']) +@routes.route('/bgp/group-peerings/<name>', methods=['GET', 'POST']) @common.require_accepts_json def bgp_group_peerings(name=None): """ @@ -277,8 +207,8 @@ def bgp_group_peerings(name=None): group_key_base='juniper-peerings:group') -@routes.route("/bgp/routing-instance-peerings", methods=['GET', 'POST']) -@routes.route("/bgp/routing-instance-peerings/<name>", methods=['GET', 'POST']) +@routes.route('/bgp/routing-instance-peerings', methods=['GET', 'POST']) +@routes.route('/bgp/routing-instance-peerings/<name>', methods=['GET', 'POST']) @common.require_accepts_json def bgp_routing_instance_peerings(name=None): """ @@ -338,7 +268,7 @@ def _handle_peering_group_list_request(cache_key, group_key_base): return Response(names, mimetype="application/json") -@routes.route("/bgp/logical-systems", methods=['GET', 'POST']) +@routes.route('/bgp/logical-systems', methods=['GET', 'POST']) @common.require_accepts_json def get_logical_systems(): """ @@ -354,7 +284,7 @@ def get_logical_systems(): group_key_base='juniper-peerings:logical-system') -@routes.route("/bgp/groups", methods=['GET', 'POST']) +@routes.route('/bgp/groups', methods=['GET', 'POST']) @common.require_accepts_json def get_peering_groups(): """ @@ -370,7 +300,7 @@ def get_peering_groups(): group_key_base='juniper-peerings:group') -@routes.route("/bgp/routing-instances", methods=['GET', 'POST']) +@routes.route('/bgp/routing-instances', methods=['GET', 'POST']) @common.require_accepts_json def get_peering_routing_instances(): """ @@ -384,3 +314,16 @@ def get_peering_routing_instances(): return _handle_peering_group_list_request( cache_key='classifier-cache:msr:routing-instances', group_key_base='juniper-peerings:routing-instance') + + +@routes.route('/access-services', methods=['GET', 'POST']) +@common.require_accepts_json +def get_access_services(): + """ + Handler for `/msr/access-services` + + Same as `/poller/services/geant_ip` + + cf. :meth:`inventory_provider.routes.poller.get_services` + """ + return get_services(service_type='geant_ip') diff --git a/inventory_provider/routes/poller.py b/inventory_provider/routes/poller.py index 4be62cbfc5e12d3161af63e2c38c2daa1b0c4e28..9561ce47a0c4a0de06a5f62fc97e00d6fb104065 100644 --- a/inventory_provider/routes/poller.py +++ b/inventory_provider/routes/poller.py @@ -29,6 +29,25 @@ These endpoints are intended for use by BRIAN. .. autofunction:: inventory_provider.routes.poller.gws_direct + +/poller/gws/indirect +--------------------------------- + +.. autofunction:: inventory_provider.routes.poller.gws_indirect + + +/poller/services</service-type> +--------------------------------- + +.. autofunction:: inventory_provider.routes.poller.get_services + + +/poller/service-types +--------------------------------- + +.. autofunction:: inventory_provider.routes.poller.get_service_types + + """ import json import logging @@ -39,6 +58,7 @@ from lxml import etree from inventory_provider import juniper from inventory_provider.routes import common +from inventory_provider.tasks.common import ims_sorted_service_type_key from inventory_provider.routes.classifier import get_ims_equipment_name, \ get_ims_interface from inventory_provider.routes.common import _ignore_cache_or_retrieve @@ -182,7 +202,7 @@ GWS_DIRECT_DATA_SCHEMA = { 'nren': {'type': 'string'}, 'isp': { 'type': 'string', - 'enum': ['Cogent', 'Telia', 'Century Link'] + 'enum': ['Cogent', 'Telia', 'CenturyLink'] }, 'hostname': {'type': 'string'}, 'tag': {'type': 'string'}, @@ -203,6 +223,74 @@ GWS_DIRECT_DATA_SCHEMA = { } +SERVICES_LIST_SCHEMA = { + '$schema': 'http://json-schema.org/draft-07/schema#', + + 'definitions': { + 'oid': { + 'type': 'string', + 'pattern': r'^(\d+\.)*\d+$' + }, + 'counter-field': { + 'type': 'object', + 'properties': { + 'field': { + 'type': 'string', + 'enum': ['egressOctets', 'ingressOctets'] + }, + 'oid': {'$ref': '#/definitions/oid'} + }, + 'required': ['field', 'oid'], + 'additionalProperties': False + }, + 'snmp-info': { + 'type': 'object', + 'properties': { + 'ifIndex': {'type': 'integer'}, + 'community': {'type': 'string'}, + 'counters': { + 'type': 'array', + 'items': {'$ref': '#/definitions/counter-field'}, + 'minItems': 1 + } + }, + 'required': ['ifIndex', 'community'], + 'additionalProperties': False + }, + 'service': { + 'type': 'object', + 'properties': { + 'id': {'type': 'integer'}, + 'name': {'type': 'string'}, + 'customer': {'type': 'string'}, + 'speed': {'type': 'integer'}, + 'pop': {'type': 'string'}, + 'hostname': {'type': 'string'}, + 'interface': {'type': 'string'}, + 'type': {'type': 'string'}, + 'status': {'type': 'string'}, + 'snmp': {'$ref': '#/definitions/snmp-info'} + }, + 'required': [ + 'id', 'name', 'customer', + 'speed', 'pop', 'hostname', + 'interface', 'type', 'status'], + 'additionalProperties': False + } + }, + + 'type': 'array', + 'items': {'$ref': '#/definitions/service'} +} + + +STRING_LIST_SCHEMA = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'array', + 'items': {'type': 'string'} +} + + @routes.after_request def after_request(resp): return common.after_request(resp) @@ -314,7 +402,9 @@ def _load_interfaces(hostname): base_key_pattern = f'netconf:{hostname}*' if hostname else 'netconf:*' yield from _load_docs(base_key_pattern) - yield from _load_docs(f'lab:{base_key_pattern}') + no_lab = common.get_bool_request_arg('no-lab', False) + if not no_lab: + yield from _load_docs(f'lab:{base_key_pattern}') def _add_bundle_parents(interfaces, hostname=None): @@ -407,6 +497,9 @@ def interfaces(hostname=None): which returns information for either all interfaces or those on the requested hostname. + The optional `no-lab` parameter omits lab routers + if it's truthiness evaluates to True. + The response is a list of information for all interfaces that should be polled, including service information and snmp information. @@ -421,6 +514,10 @@ def interfaces(hostname=None): cache_key = f'classifier-cache:poller-interfaces:{hostname}' \ if hostname else 'classifier-cache:poller-interfaces:all' + no_lab = common.get_bool_request_arg('no-lab', False) + if no_lab: + cache_key = f'{cache_key}:no_lab' + r = common.get_current_redis() result = _ignore_cache_or_retrieve(request, cache_key, r) @@ -623,7 +720,7 @@ def gws_direct(): inventory_provider.routes.poller.GWS_DIRECT_DATA_SCHEMA WARNING: interface tags in the `gws-direct` section of the config data - should be unique for each nren/isp/hostname combination. i.e. if there + should be unique for each nren/isp combination. i.e. if there are multiple community strings in use for a particular host, then please keep the interface tags unique. @@ -662,3 +759,278 @@ def gws_direct(): r.set(cache_key, result.encode('utf-8')) return Response(result, mimetype="application/json") + + +# cf. https://gitlab.geant.net/puppet-apps/cacti/-/blob/production/files/scripts/juniper-firewall-dws.pl # noqa: E501 + +JNX_DCU_STATS_BYTES_OID = '1.3.6.1.4.1.2636.3.6.2.1.5' +JNX_FW_COUNTER_BYTES_OID = '1.3.6.1.4.1.2636.3.5.2.1.5' + +JNX_ADDRESS_FAMILY = { + 'ipv4': 1, + 'ipv6': 2 +} + +JNX_FW_COUNTER_TYPE = { + 'other': 1, + 'counter': 2, + 'policer': 3 +} + + +def _str2oid(s): + chars = '.'.join(str(ord(c)) for c in s) + return f'{len(s)}.{chars}' + + +def _jnx_dcu_byte_count_oid( + ifIndex, + class_name='dws-in', + address_family=JNX_ADDRESS_FAMILY['ipv4']): + # sanity checks (in case of programming errors) + assert isinstance(ifIndex, int) + assert isinstance(class_name, str) + assert isinstance(address_family, int) + return '.'.join([ + JNX_DCU_STATS_BYTES_OID, + str(ifIndex), + str(address_family), + _str2oid(class_name) + ]) + + +def _jnx_fw_counter_bytes_oid( + customer, + interface_name, + filter_name=None, + counter_name=None): + # sanity checks (in case of programming errors) + assert isinstance(customer, str) + assert isinstance(interface_name, str) + assert filter_name is None or isinstance(filter_name, str) + assert counter_name is None or isinstance(counter_name, str) + + if filter_name is None: + filter_name = f'nren_IAS_{customer}_OUT-{interface_name}-o' + if counter_name is None: + counter_name = f'DWS-out-{interface_name}-o' + + return '.'.join([ + JNX_FW_COUNTER_BYTES_OID, + _str2oid(filter_name), + _str2oid(counter_name), + str(JNX_FW_COUNTER_TYPE['counter']) + ]) + + +def _get_services_internal(service_type=None): + """ + Performs the lookup and caching done for calls to + `/poller/services</service-type>` + + This is a separate private utility so that it can be called by + :meth:`inventory_provider.routes.poller.get_services` + and :meth:`inventory_provider.routes.poller.get_service_types` + + The response will be formatted according to the following schema: + + .. asjson:: + inventory_provider.routes.poller.SERVICES_LIST_SCHEMA + + :param service_type: a service type, or None to return all + :return: service list, json-serialized to a string + """ + + return_all = common.get_bool_request_arg('all', False) + include_snmp = common.get_bool_request_arg('snmp', False) + + def _services(): + key_pattern = f'ims:services:{service_type}:*' \ + if service_type else 'ims:services:*' + + for doc in common.load_json_docs( + config_params=current_app.config['INVENTORY_PROVIDER_CONFIG'], + key_pattern=key_pattern, + num_threads=20): + yield doc['value'] + + def _add_snmp(s): + all_snmp_info = common.load_snmp_indexes() + snmp_interfaces = all_snmp_info.get(s['hostname'], {}) + interface_info = snmp_interfaces.get(s['interface'], None) + if interface_info: + s['snmp'] = { + 'ifIndex': interface_info['index'], + 'community': interface_info['community'], + } + if s['type'] == 'GWS - INDIRECT': + s['snmp']['counters'] = [ + { + 'field': 'ingressOctets', + 'oid': _jnx_dcu_byte_count_oid( + interface_info['index']), + }, + { + 'field': 'egressOctets', + 'oid': _jnx_fw_counter_bytes_oid( + s['customer'], s['interface']) + } + ] + return s + + def _wanted_in_output(s): + return return_all or (s['status'].lower() == 'operational') + + def _format_services(s): + return { + 'id': s['id'], + 'name': s['name'], + 'customer': s['project'], + 'speed': s['speed_value'], + 'pop': s['here']['pop']['name'], + 'hostname': common.ims_equipment_to_hostname( + s['here']['equipment']), + 'interface': s['here']['port'].lower(), + 'type': s['type'], + 'status': s['status'] + } + + cache_key = f'classifier-cache:poller:services:{service_type}' \ + if service_type else 'classifier-cache:poller:services:all-types' + + if return_all: + cache_key = f'{cache_key}:all' + if include_snmp: + cache_key = f'{cache_key}:snmp' + + redis = common.get_current_redis() + result = _ignore_cache_or_retrieve(request, cache_key, redis) + + if not result: + result = _services() + result = filter(_wanted_in_output, result) + result = map(_format_services, result) + if include_snmp: + result = map(_add_snmp, result) + result = list(result) + + if not result: + return None + + # cache this data for the next call + result = json.dumps(result) + redis.set(cache_key, result) + + return result + + +@routes.route('/services', methods=['GET', 'POST']) +@routes.route('/services/<service_type>', methods=['GET', 'POST']) +@common.require_accepts_json +def get_services(service_type=None): + """ + Handler for `/poller/services</service-type>` + + Use `/poller/service-types` for possible values of `service-type`. + + If the endpoint is called with no `service-type`, + all services are returned. + + Supported url parameters: + + :all: Default is False to return only operational services. + If present and evaluates to True, then return all services. + :snmp: If present and evalutes to True, add snmp interface polling + params to the response. + + (sphinx bug: the param names aren't capitalized) + + The response will be formatted according to the following schema: + + .. asjson:: + inventory_provider.routes.poller.SERVICES_LIST_SCHEMA + + A few possible values for `service_type` are (currently): gws_internal, + geant_ip, geant_lambda, l3_vpn, md_vpn_proxy (there are more) + + If the endpoint is called with no `service_type`, + all services are returned + + :return: + """ + + services_json_str = _get_services_internal(service_type) + + if not services_json_str: + message = f'no {service_type} services found' \ + if service_type else 'no services found' + return Response( + response=message, + status=404, + mimetype='text/html') + + return Response(services_json_str, mimetype='application/json') + + +@routes.route("/gws/indirect", methods=['GET', 'POST']) +@common.require_accepts_json +def gws_indirect(): + """ + Handler for `/poller/gws/indirect` + + Same as `/poller/services/gws_indirect` + + cf. :meth:`inventory_provider.routes.poller.get_services` + :return: + """ + return get_services(service_type='gws_indirect') + + +@routes.route('/service-types', methods=['GET', 'POST']) +@common.require_accepts_json +def get_service_types(): + """ + Handler for `/poller/service-types` + + This method returns a list of all values of `service_type` that + can be used with `/poller/services/service-type` to return + a non-empty list of services. + + Adding a truthy `all` request parameter (e.g. `?all=1`) will + return also return valid service types for which none of the + defined services are operational. + + The response will be formatted according to the following schema: + + .. asjson:: + inventory_provider.routes.poller.STRING_LIST_SCHEMA + + """ + cache_key = 'classifier-cache:poller:service-types' + + return_all = common.get_bool_request_arg('all', False) + if return_all: + cache_key = f'{cache_key}-all' + + redis = common.get_current_redis() + service_types = _ignore_cache_or_retrieve(request, cache_key, redis) + + if not service_types: + all_services = json.loads(_get_services_internal()) + service_types = { + ims_sorted_service_type_key(s['type']) + for s in all_services + } + + if not service_types: + return Response( + response='no service types found', + status=404, + mimetype='text/html') + + # cache this data for the next call + service_types = sorted(list(service_types)) + service_types = json.dumps(service_types) + redis.set(cache_key, service_types) + + return Response(service_types, mimetype='application/json') diff --git a/inventory_provider/tasks/common.py b/inventory_provider/tasks/common.py index 427d51d8a18e4128f3dd3b174274e65fa17f5f8f..ad929ed4677cee9750f0b9b52c2846ecc26584d1 100644 --- a/inventory_provider/tasks/common.py +++ b/inventory_provider/tasks/common.py @@ -1,5 +1,6 @@ import json import logging +import re import time import jsonschema @@ -218,3 +219,23 @@ def get_next_redis(config): 'derived next id: {}'.format(next_id)) return _get_redis(config, next_id) + + +def ims_sorted_service_type_key(service_type): + """ + special-purpose function used for mapping IMS service type strings + to the key used in redis ('ims:services:*') + + this method is only used by + :meth:`inventory_provider:tasks:worker.update_circuit_hierarchy_and_port_id_services + and :meth:`inventory_provider:routes.poller.get_service_types + + :param service_type: IMS-formatted service_type string + :return: ims sorted service type redis key + """ # noqa: E501 + + service_type = re.sub( + r'[^a-zA-Z0-9]+', '_', service_type.lower()) + # prettification ... e.g. no trailing _ for 'MD-VPN (NATIVE)' + service_type = re.sub('_+$', '', service_type) + return re.sub('^_+', '', service_type) diff --git a/inventory_provider/tasks/worker.py b/inventory_provider/tasks/worker.py index a36a5faf2214f572c9f0eca9170fea3782f51e84..544e10c098db1343ae249718c6011d50102b629b 100644 --- a/inventory_provider/tasks/worker.py +++ b/inventory_provider/tasks/worker.py @@ -29,7 +29,8 @@ from inventory_provider.db.ims import IMS from inventory_provider.tasks.app import app from inventory_provider.tasks.common \ import get_next_redis, get_current_redis, \ - latch_db, get_latch, set_latch, update_latch_status + latch_db, get_latch, set_latch, update_latch_status, \ + ims_sorted_service_type_key from inventory_provider.tasks import monitor from inventory_provider import config from inventory_provider import environment @@ -573,12 +574,14 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): {k: v for k, v in ims_data.get_customer_service_emails(ds1)} circuit_ids_to_monitor = \ list(ims_data.get_monitored_circuit_ids(ds1)) + additional_circuit_customer_ids = \ + ims_data.get_circuit_related_customer_ids(ds1) hierarchy = None port_id_details = defaultdict(list) port_id_services = defaultdict(list) interface_services = defaultdict(list) - access_services = {} + services_by_type = {} def _convert_to_bits(value, unit): unit = unit.lower() @@ -612,12 +615,18 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): else: return 0 + def _get_circuit_contacts(c): + customer_ids = {c['customerid']} + customer_ids.update(additional_circuit_customer_ids.get(c['id'], [])) + return set().union( + *[customer_contacts.get(cid, []) for cid in customer_ids]) + def _populate_hierarchy(): nonlocal hierarchy hierarchy = {} for d in ims_data.get_circuit_hierarchy(ds1): - d['contacts'] = customer_contacts.get(d['customerid'], []) hierarchy[d['id']] = d + d['contacts'] = sorted(list(_get_circuit_contacts(d))) logger.debug("hierarchy complete") def _populate_port_id_details(): @@ -671,7 +680,7 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): 'circuit_type': c['circuit-type'], 'service_type': c['product'], 'project': c['project'], - 'contacts': sorted(list(c['contacts'])) + 'contacts': c['contacts'] } if c['id'] in circuit_ids_to_monitor: rs[c['id']]['status'] = c['status'] @@ -736,13 +745,7 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): circuits = port_id_services.get(details['port_id'], []) for circ in circuits: - contacts = set() - contacts.update( - customer_contacts.get( - circ['customerid'], - [] - ) - ) + contacts = _get_circuit_contacts(circ) circ['fibre-routes'] = [] for x in set(_get_fibre_routes(circ['id'])): c = { @@ -762,8 +765,9 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): circ['calculated-speed'] = _get_speed(circ['id']) _format_service(circ) - if circ['service_type'].lower() == 'geant ip': - access_services[circ['id']] = circ + type_services = services_by_type.setdefault( + ims_sorted_service_type_key(circ['service_type']), dict()) + type_services[circ['id']] = circ interface_services[k].extend(circuits) @@ -782,6 +786,8 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): rp = r.pipeline() for k in r.scan_iter('ims:access_services:*', count=1000): rp.delete(k) + for k in r.scan_iter('ims:gws_indirect:*', count=1000): + rp.delete(k) rp.execute() rp = r.pipeline() for circ in hierarchy.values(): @@ -797,19 +803,36 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): populate_poller_cache(interface_services, r) - for v in access_services.values(): - rp.set( - f'ims:access_services:{v["name"]}', - json.dumps({ - 'id': v['id'], - 'name': v['name'], - 'pop_name': v['pop_name'], - 'other_end_pop_name': v['other_end_pop_name'], - 'equipment': v['equipment'], - 'other_end_equipment': v['other_end_equipment'], - 'speed_value': v['calculated-speed'], - 'speed_unit': 'n/a' - })) + for service_type, services in services_by_type.items(): + for v in services.values(): + rp.set( + f'ims:services:{service_type}:{v["name"]}', + json.dumps({ + 'id': v['id'], + 'name': v['name'], + 'project': v['project'], + 'here': { + 'pop': { + 'name': v['pop_name'], + 'abbreviation': v['pop_abbreviation'] + }, + 'equipment': v['equipment'], + 'port': v['port'], + }, + 'there': { + 'pop': { + 'name': v['other_end_pop_name'], + 'abbreviation': v['other_end_pop_abbreviation'] + }, + 'equipment': v['other_end_equipment'], + 'port': v['other_end_port'], + }, + 'speed_value': v['calculated-speed'], + 'speed_unit': 'n/a', + 'status': v['status'], + 'type': v['service_type'] + })) + rp.execute() diff --git a/requirements.txt b/requirements.txt index dac8c3c31d676c037b165cb45c2f13b04f8815e7..949b83d8a8781bf7ceb7685c45d48667aa95a358 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ pysnmp jsonschema==3.2.0 paramiko flask +flask-cors redis==3.2.1 kombu==4.5.0 vine==1.3.0 diff --git a/setup.py b/setup.py index cf2c00e3b7ffd27eaeefce6a6508f7fce1c4c8a1..e7c146e3351fff602b48eb5317b9a2d4eb2306ae 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name='inventory-provider', - version="0.66", + version="0.67", author='GEANT', author_email='swd@geant.org', description='Dashboard inventory provider', @@ -16,6 +16,7 @@ setup( 'jsonschema==3.2.0', 'paramiko', 'flask', + 'flask-cors', 'redis==3.2.1', 'kombu==4.5.0', 'vine==1.3.0', diff --git a/test/data/gws-direct.json b/test/data/gws-direct.json index 8fcb50564dd4687b74ff105e62423d75501e999c..db422736b59a697fda797b4a1f9390fc8f430ecd 100644 --- a/test/data/gws-direct.json +++ b/test/data/gws-direct.json @@ -218,7 +218,7 @@ }, { "nren": "RoEduNet", - "isp": "Century Link", + "isp": "CenturyLink", "hosts": [ { "hostname": "212.162.45.194", @@ -256,7 +256,7 @@ }, { "nren": "PSNC", - "isp": "Century Link", + "isp": "CenturyLink", "hosts": [ { "hostname": "212.191.126.6", diff --git a/test/data/router-info.json b/test/data/router-info.json index 14e59ab1c12192c95f1949146c5e44857b6fedb3..fdb6beb945efaf539dabc43b823580b731f7ae05 100644 Binary files a/test/data/router-info.json and b/test/data/router-info.json differ diff --git a/test/data/update-test-db.py b/test/data/update-test-db.py index 1567466fe81c46534aa5f293e66f9e9a92c6ccb6..b615e898b76227081fdc86b160cdd92451bb247d 100644 --- a/test/data/update-test-db.py +++ b/test/data/update-test-db.py @@ -107,11 +107,10 @@ if __name__ == '__main__': # ] # }, { - 'hostname': 'localhost', + 'hostname': 'test-dashboard-storage03.geant.org', 'db-index': 0, 'key-patterns': [ - 'ims:interface_services*', - 'ims:circuit_hierarchy*', + '*' ] }, ] diff --git a/test/test_general_poller_routes.py b/test/test_general_poller_routes.py index 7a7807c6053c9522a6fdaba7c2b176a0f5c51f2c..e17d8f3f6c1cbd2438020a7b21740bf7390bd05b 100644 --- a/test/test_general_poller_routes.py +++ b/test/test_general_poller_routes.py @@ -1,5 +1,6 @@ import json import jsonschema +import pytest from inventory_provider.routes import poller DEFAULT_REQUEST_HEADERS = { @@ -19,6 +20,21 @@ def test_get_all_interfaces(client): response_routers = {ifc['router'] for ifc in response_data} assert len(response_routers) > 1, \ 'there should data from be lots of routers' + assert any('.lab.' in name for name in response_routers) + + +def test_get_all_interfaces_no_lab(client): + rv = client.get( + '/poller/interfaces?no-lab=1', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.INTERFACE_LIST_SCHEMA) + response_routers = {ifc['router'] for ifc in response_data} + assert len(response_routers) > 1, \ + 'there should data from be lots of routers' + assert all('.lab.' not in name for name in response_routers) def test_all_router_interface_speeds(client): @@ -62,3 +78,140 @@ def test_gws_direct(client): response_data = json.loads(rv.data.decode('utf-8')) jsonschema.validate(response_data, poller.GWS_DIRECT_DATA_SCHEMA) assert response_data, "the subscription list shouldn't be empty" + + +def test_gws_indirect(client): + rv = client.get( + '/poller/gws/indirect', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.SERVICES_LIST_SCHEMA) + + assert response_data # test data is non-empty + assert all(s['type'] == 'GWS - INDIRECT' for s in response_data) + + +def test_gws_indirect_snmp(client): + # same as test_services_snmp, but also verify that + # the snmp data in the response contains counters + rv = client.get( + '/poller/services/gws_indirect?snmp=1', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.SERVICES_LIST_SCHEMA) + + assert response_data # test data is non-empty + # all access services should have snmp info + assert all('snmp' in s for s in response_data) + assert all('counters' in s['snmp'] for s in response_data) + + +def test_all_services_default(client): + rv = client.get( + '/poller/services', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.SERVICES_LIST_SCHEMA) + + assert response_data # test data is non-empty + assert all(s['status'] == 'operational' for s in response_data) + + +def test_all_services_all(client): + rv = client.get( + '/poller/services?all=1', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.SERVICES_LIST_SCHEMA) + + assert response_data # test data is non-empty + assert any(s['status'] != 'operational' for s in response_data) + assert all('snmp' not in s for s in response_data) + + +@pytest.mark.parametrize('service_type', [ + # services with these types should all have snmp configs + 'gws_indirect', 'geant_ip', 'l3_vpn']) +def test_services_snmp(client, service_type): + + rv = client.get( + f'/poller/services/{service_type}?snmp=1', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.SERVICES_LIST_SCHEMA) + + assert response_data # test data is non-empty + # all access services should have snmp info + assert all('snmp' in s for s in response_data) + + +@pytest.mark.parametrize('uri_type,expected_type', [ + ('gws_indirect', 'GWS - INDIRECT'), + ('gws_upstream', 'GWS - UPSTREAM'), + ('geant_ip', 'GEANT IP'), + ('geant_lambda', 'GEANT LAMBDA'), + # ('gts', 'GTS'), # these are non-monitored (TODO: make a flag?) + ('md_vpn_native', 'MD-VPN (NATIVE)'), + ('md_vpn_proxy', 'MD-VPN (PROXY)'), + ('cbl1', 'CBL1'), + ('l3_vpn', 'L3-VPN') +]) +def test_all_services_by_type(client, uri_type, expected_type): + rv = client.get( + f'/poller/services/{uri_type}', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.SERVICES_LIST_SCHEMA) + + assert response_data # test data is non-empty + assert all(s['type'] == expected_type for s in response_data) + + +def test_get_service_types(client): + rv = client.get( + '/poller/service-types', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, poller.STRING_LIST_SCHEMA) + + assert response_data # test data is non-empty + # some hard-coded values we expect to see from the test data ... + assert 'gws_indirect' in response_data + assert 'geant_ip' in response_data + + +@pytest.mark.parametrize('ifIndex,expected_oid', [ + (595, '1.3.6.1.4.1.2636.3.6.2.1.5.595.1.6.100.119.115.45.105.110'), + (607, '1.3.6.1.4.1.2636.3.6.2.1.5.607.1.6.100.119.115.45.105.110'), + (1135, '1.3.6.1.4.1.2636.3.6.2.1.5.1135.1.6.100.119.115.45.105.110') +]) +def test_dcu_oid_values(ifIndex, expected_oid): + assert poller._jnx_dcu_byte_count_oid(ifIndex) == expected_oid + + +@pytest.mark.parametrize('customer, interface_name, expected_oid', [ + ('ASREN', 'ae17.333', '1.3.6.1.4.1.2636.3.5.2.1.5.29.110.114.101.110.95.73.65.83.95.65.83.82.69.78.95.79.85.84.45.97.101.49.55.46.51.51.51.45.111.18.68.87.83.45.111.117.116.45.97.101.49.55.46.51.51.51.45.111.2'), # noqa: E501 + ('FCCN', 'ae10.333', '1.3.6.1.4.1.2636.3.5.2.1.5.28.110.114.101.110.95.73.65.83.95.70.67.67.78.95.79.85.84.45.97.101.49.48.46.51.51.51.45.111.18.68.87.83.45.111.117.116.45.97.101.49.48.46.51.51.51.45.111.2'), # noqa: E501 + ('GRNET', 'ae11.333', '1.3.6.1.4.1.2636.3.5.2.1.5.29.110.114.101.110.95.73.65.83.95.71.82.78.69.84.95.79.85.84.45.97.101.49.49.46.51.51.51.45.111.18.68.87.83.45.111.117.116.45.97.101.49.49.46.51.51.51.45.111.2'), # noqa: #E501 + ('GRNET', 'ae10.333', '1.3.6.1.4.1.2636.3.5.2.1.5.29.110.114.101.110.95.73.65.83.95.71.82.78.69.84.95.79.85.84.45.97.101.49.48.46.51.51.51.45.111.18.68.87.83.45.111.117.116.45.97.101.49.48.46.51.51.51.45.111.2'), # noqa: #E501 + ('ULAKBIM', 'ae11.333', '1.3.6.1.4.1.2636.3.5.2.1.5.31.110.114.101.110.95.73.65.83.95.85.76.65.75.66.73.77.95.79.85.84.45.97.101.49.49.46.51.51.51.45.111.18.68.87.83.45.111.117.116.45.97.101.49.49.46.51.51.51.45.111.2'), # noqa: E501 + ('UOM', 'xe-11/0/0.333', '1.3.6.1.4.1.2636.3.5.2.1.5.32.110.114.101.110.95.73.65.83.95.85.79.77.95.79.85.84.45.120.101.45.49.49.47.48.47.48.46.51.51.51.45.111.23.68.87.83.45.111.117.116.45.120.101.45.49.49.47.48.47.48.46.51.51.51.45.111.2'), # noqa: E501 + ('LITNET', 'xe-0/1/1.333', '1.3.6.1.4.1.2636.3.5.2.1.5.34.110.114.101.110.95.73.65.83.95.76.73.84.78.69.84.95.79.85.84.45.120.101.45.48.47.49.47.49.46.51.51.51.45.111.22.68.87.83.45.111.117.116.45.120.101.45.48.47.49.47.49.46.51.51.51.45.111.2'), # noqa: E501 +]) +def test_fw_counter_bytes_oid_values(customer, interface_name, expected_oid): + assert poller._jnx_fw_counter_bytes_oid( + customer, interface_name) == expected_oid diff --git a/test/test_msr_routes.py b/test/test_msr_routes.py index 403321060059c7e5895a388ba8317081f86362db..3788bdeb08a03adcb7e1dba504363bffcaab9b99 100644 --- a/test/test_msr_routes.py +++ b/test/test_msr_routes.py @@ -4,7 +4,8 @@ import jsonschema import pytest from inventory_provider.routes.msr import PEERING_LIST_SCHEMA, \ - PEERING_GROUP_LIST_SCHEMA, ACCESS_SERVICES_LIST_SCHEMA + PEERING_GROUP_LIST_SCHEMA +from inventory_provider.routes.poller import SERVICES_LIST_SCHEMA DEFAULT_REQUEST_HEADERS = { "Content-type": "application/json", @@ -13,18 +14,16 @@ DEFAULT_REQUEST_HEADERS = { def test_access_services(client): - - # todo - fix once IMS msr code is done - rv = client.get( '/msr/access-services', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json response_data = json.loads(rv.data.decode('utf-8')) - jsonschema.validate(response_data, ACCESS_SERVICES_LIST_SCHEMA) + jsonschema.validate(response_data, SERVICES_LIST_SCHEMA) assert response_data # test data is non-empty + assert all(s['type'] == 'GEANT IP' for s in response_data) def test_logical_system_peerings_all(client):