diff --git a/inventory_provider/routes/common.py b/inventory_provider/routes/common.py index a025f3a39c23abe26ad28c7d20cd7cc54663889a..8f286962b766ff2be1e5eb830af15a90d744a407 100644 --- a/inventory_provider/routes/common.py +++ b/inventory_provider/routes/common.py @@ -18,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') @@ -255,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 def load_snmp_indexes(hostname=None): result = dict() key_pattern = f'snmp-interfaces:{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/poller.py b/inventory_provider/routes/poller.py index d812b0a8b1ebe34254a50752ddf615a6115db25f..a8942266980c6314be6928d2216ae90f5bdeb928 100644 --- a/inventory_provider/routes/poller.py +++ b/inventory_provider/routes/poller.py @@ -36,12 +36,18 @@ These endpoints are intended for use by BRIAN. .. autofunction:: inventory_provider.routes.poller.gws_indirect -/poller/services</type> +/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 @@ -52,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 @@ -220,6 +227,15 @@ SERVICES_LIST_SCHEMA = { '$schema': 'http://json-schema.org/draft-07/schema#', 'definitions': { + 'snmp-info': { + 'type': 'object', + 'properties': { + 'ifIndex': {'type': 'integer'}, + 'community': {'type': 'string'} + }, + 'required': ['ifIndex', 'community'], + 'additionalProperties': False + }, 'service': { 'type': 'object', 'properties': { @@ -230,12 +246,14 @@ SERVICES_LIST_SCHEMA = { 'pop': {'type': 'string'}, 'hostname': {'type': 'string'}, 'interface': {'type': 'string'}, - 'type': {'type': 'string'} + 'type': {'type': 'string'}, + 'status': {'type': 'string'}, + 'snmp': {'$ref': '#/definitions/snmp-info'} }, 'required': [ 'id', 'name', 'customer', 'speed', 'pop', 'hostname', - 'interface', 'type'], + 'interface', 'type', 'status'], 'additionalProperties': False } }, @@ -245,6 +263,13 @@ SERVICES_LIST_SCHEMA = { } +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) @@ -706,26 +731,27 @@ def gws_direct(): return Response(result, mimetype="application/json") -@routes.route('/services', methods=['GET', 'POST']) -@routes.route('/services/<service_type>', methods=['GET', 'POST']) -@common.require_accepts_json -def get_services(service_type=None): +def _get_services_internal(service_type=None): """ - Handler for `/poller/services/service_type`. + 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 - A few possible values for `service_type` are (currently): gws_internal, - geant_ip, geant_lambda, l3_vpn, md_vpn_proxy (there are more) + :param service_type: a service type, or None to return all + :return: service list, json-serialized to a string + """ - If the endpoint is called with no `service_type`, - all services are returned + return_all = common.get_bool_request_arg('all', False) + include_snmp = common.get_bool_request_arg('snmp', False) - :return: - """ def _services(): key_pattern = f'ims:services:{service_type}:*' \ if service_type else 'ims:services:*' @@ -736,8 +762,19 @@ def get_services(service_type=None): num_threads=20): yield doc['value'] - def _operational(s): - return s['status'].lower() == 'operational' + 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'] + } + return s + + def _wanted_in_output(s): + return return_all or (s['status'].lower() == 'operational') def _format_services(s): return { @@ -748,35 +785,86 @@ def get_services(service_type=None): 'pop': s['here']['pop']['name'], 'hostname': common.ims_equipment_to_hostname( s['here']['equipment']), - 'interface': s['here']['port'], - 'type': s['type'] + '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' + 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(_operational, result) + 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: - message = f'no {service_type} services found' \ - if service_type else 'no services found' - return Response( - response=message, - status=404, - mimetype='text/html') + return None # cache this data for the next call result = json.dumps(result) - redis.set(cache_key, result.encode('utf-8')) + 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`. - return Response(result, mimetype='application/json') + 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']) @@ -791,3 +879,53 @@ def gws_indirect(): :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 58bccc8252ffda083a0243fa4def71477e6f7200..4d7e33c506390db0a5b76b129dabe06d02156ffd 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 @@ -762,14 +763,8 @@ def update_circuit_hierarchy_and_port_id_services(self, use_current=False): circ['calculated-speed'] = _get_speed(circ['id']) _format_service(circ) - service_type_key = re.sub( - r'[^a-zA-Z0-9]+', '_', circ['service_type'].lower()) - # prettification ... e.g. no trailing _ for 'MD-VPN (NATIVE)' - service_type_key = re.sub('_+$', '', service_type_key) - service_type_key = re.sub('^_+', '', service_type_key) - type_services = services_by_type.setdefault( - service_type_key, dict()) + ims_sorted_service_type_key(circ['service_type']), dict()) type_services[circ['id']] = circ interface_services[k].extend(circuits) diff --git a/test/test_general_poller_routes.py b/test/test_general_poller_routes.py index b2179230221b5d5b2cc2c4fbeec8196512016b12..b00dc988719a5319581d15abb9a2416b3dfc3cd0 100644 --- a/test/test_general_poller_routes.py +++ b/test/test_general_poller_routes.py @@ -78,7 +78,7 @@ def test_gws_indirect(client): assert all(s['type'] == 'GWS - INDIRECT' for s in response_data) -def test_all_services(client): +def test_all_services_default(client): rv = client.get( '/poller/services', headers=DEFAULT_REQUEST_HEADERS) @@ -88,6 +88,39 @@ def test_all_services(client): 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', [ @@ -112,3 +145,18 @@ def test_all_services_by_type(client, uri_type, expected_type): 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