diff --git a/README.md b/README.md index 42b82c128278347de901acaa1fd98f8321b519a9..20c0231d63f55eb61d986def42a89c955b3803c7 100644 --- a/README.md +++ b/README.md @@ -310,5 +310,46 @@ Any non-empty responses are JSON formatted messages. "type": "object" } ``` - -### Test Commit \ No newline at end of file + +* /poller/interfaces/*`hostname`* + + The response will be the list of active interfaces on the + router `hostname`. + Each element of the returned list contains information necessary + for setting up snmp throughput counter polling. + + The response will be formatted according to the following schema: + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "circuit": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "id": {"type": "integer"} + }, + "required": ["type", "id"], + "additionalProperties": False + } + }, + + "type": "array", + "items": { + "type": "object", + "properties": { + "circuits": { + "type": "array", + "items": {"$ref": "#/definitions/circuit"} + }, + "description": {"type": "string"}, + "name": {"type": "string"}, + "snmp-index": {"type": "integer"} + }, + "required": ["circuits", "description", "name", "snmp-index"], + "additionalProperties": False + } + } + ``` diff --git a/inventory_provider/__init__.py b/inventory_provider/__init__.py index a8adb17fc82ed6aca7c697d723990e18c98cb710..5de6a3115f4d1af8539195df786e39e9e0d1906b 100644 --- a/inventory_provider/__init__.py +++ b/inventory_provider/__init__.py @@ -29,6 +29,9 @@ def create_app(): from inventory_provider.routes import classifier app.register_blueprint(classifier.routes, url_prefix='/classifier') + from inventory_provider.routes import poller + app.register_blueprint(poller.routes, url_prefix='/poller') + if "SETTINGS_FILENAME" not in os.environ: assert False, \ "environment variable SETTINGS_FILENAME' must be defined" diff --git a/inventory_provider/db.py b/inventory_provider/db.py index 2de869b3bb39b7acaa46a624465b026934c228a4..086386c6986ab11268920a529f390edeae0a9e2c 100644 --- a/inventory_provider/db.py +++ b/inventory_provider/db.py @@ -1,18 +1,5 @@ import contextlib import mysql.connector -import redis - -from flask import current_app, g - - -def get_redis(): # pragma: no cover - if 'redis_db' not in g: - config = current_app.config['INVENTORY_PROVIDER_CONFIG'] - g.redis_db = redis.Redis( - host=config['redis']['hostname'], - port=config['redis']['port']) - - return g.redis_db @contextlib.contextmanager diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py index 1fbb0793a182a2b0d6b79b09c32801636b4155d7..9f3c35277e1e1f6478888593f0f9402153263f56 100644 --- a/inventory_provider/routes/classifier.py +++ b/inventory_provider/routes/classifier.py @@ -4,7 +4,7 @@ from flask import Blueprint, request, Response, current_app, jsonify import json import jsonschema -from inventory_provider import db +from inventory_provider.routes import common routes = Blueprint("inventory-data-classifier-support-routes", __name__) @@ -59,7 +59,7 @@ def juniper_addresses(): } } - servers = db.get_redis().get('alarmsdb:juniper_servers') + servers = common.get_redis().get('alarmsdb:juniper_servers') if not servers: return Response( response="no juniper server data found", @@ -77,7 +77,7 @@ def juniper_addresses(): def get_trap_metadata(trap_type, source_equipment, interface): # todo - Move this to config interface_info_key = "interface_services" - r = db.get_redis() + r = common.get_redis() # todo - Change this to a call to the yet-to-be-created one source of all # relevant information diff --git a/inventory_provider/routes/common.py b/inventory_provider/routes/common.py new file mode 100644 index 0000000000000000000000000000000000000000..c51ac11776ddb891a30b865b30f8176e5cf799c0 --- /dev/null +++ b/inventory_provider/routes/common.py @@ -0,0 +1,33 @@ +import functools + +from flask import request, Response, current_app, g +import redis + + +def get_redis(): + if 'redis_db' not in g: + config = current_app.config['INVENTORY_PROVIDER_CONFIG'] + g.redis_db = redis.StrictRedis( + host=config['redis']['hostname'], + port=config['redis']['port']) + + return g.redis_db + + +def require_accepts_json(f): + """ + used as a route handler decorator to return an error + unless the request allows responses with type "application/json" + :param f: the function to be decorated + :return: the decorated function + """ + @functools.wraps(f) + def decorated_function(*args, **kwargs): + # TODO: use best_match to disallow */* ...? + if not request.accept_mimetypes.accept_json: + return Response( + response="response will be json", + status=406, + mimetype="text/html") + return f(*args, **kwargs) + return decorated_function diff --git a/inventory_provider/routes/data.py b/inventory_provider/routes/data.py index 7b819151faf1a9ca1527cbe140f56072b8fd2ab8..ce73e476af52f131cdbf139a6f3c38d7bfebebfd 100644 --- a/inventory_provider/routes/data.py +++ b/inventory_provider/routes/data.py @@ -6,7 +6,8 @@ from flask import Blueprint, jsonify, request, Response, current_app from lxml import etree import redis -from inventory_provider import db, juniper +from inventory_provider import juniper +from inventory_provider.routes import common routes = Blueprint("inventory-data-query-routes", __name__) @@ -168,7 +169,7 @@ def bgp_configs(hostname): methods=['GET', 'POST']) @require_accepts_json def interface_statuses(hostname, interface): - r = db.get_redis() + r = common.get_redis() result = r.hget("interface_statuses", "{}::{}".format(hostname, interface)) if not result: @@ -182,7 +183,7 @@ def interface_statuses(hostname, interface): @routes.route("/services/<hostname>/<path:interface>", methods=['GET', 'POST']) def services_for_interface(hostname, interface): - r = db.get_redis() + r = common.get_redis() result = r.hget("interface_services", "{}::{}".format(hostname, interface)) if not result: diff --git a/inventory_provider/routes/opsdb.py b/inventory_provider/routes/opsdb.py index c49fe1f269e04944aca61cb69e5cb158648185a7..84faab3d11dafb639906079c4086949f56e320db 100644 --- a/inventory_provider/routes/opsdb.py +++ b/inventory_provider/routes/opsdb.py @@ -2,7 +2,7 @@ import functools import json from flask import Blueprint, request, Response -from inventory_provider import db +from inventory_provider.routes import common routes = Blueprint("inventory-opsdb-query-routes", __name__) @@ -38,7 +38,7 @@ def _decode_utf8_dict(d): @routes.route("/interfaces") def get_all_interface_details(): - r = db.get_redis() + r = common.get_redis() result = _decode_utf8_dict( r.hgetall(interfaces_key)) @@ -49,7 +49,7 @@ def get_all_interface_details(): @routes.route("/interfaces/<equipment_name>") def get_interface_details_for_equipment(equipment_name): - r = db.get_redis() + r = common.get_redis() result = {} for t in r.hscan_iter(interfaces_key, "{}::*".format(equipment_name)): result[t[0].decode("utf8")] = json.loads(t[1]) @@ -61,7 +61,7 @@ def get_interface_details_for_equipment(equipment_name): @routes.route("/interfaces/<equipment_name>/<path:interface>") def get_interface_details(equipment_name, interface): - r = db.get_redis() + r = common.get_redis() return Response( r.hget( interfaces_key, @@ -71,7 +71,7 @@ def get_interface_details(equipment_name, interface): @routes.route("/equipment-location") def get_all_equipment_locations(): - r = db.get_redis() + r = common.get_redis() result = list( _decode_utf8_dict( r.hgetall(equipment_locations_key)).values()) @@ -83,7 +83,7 @@ def get_all_equipment_locations(): @routes.route("/equipment-location/<path:equipment_name>") def get_equipment_location(equipment_name): - r = db.get_redis() + r = common.get_redis() return Response( r.hget(equipment_locations_key, equipment_name), mimetype="application/json") @@ -91,7 +91,7 @@ def get_equipment_location(equipment_name): @routes.route("/circuit-hierarchy/children/<parent_id>") def get_children(parent_id): - r = db.get_redis() + r = common.get_redis() return Response( r.hget( service_parent_to_children_key, @@ -101,7 +101,7 @@ def get_children(parent_id): @routes.route("/circuit-hierarchy/parents/<child_id>") def get_parents(child_id): - r = db.get_redis() + r = common.get_redis() return Response( r.hget( service_child_to_parents_key, diff --git a/inventory_provider/routes/poller.py b/inventory_provider/routes/poller.py new file mode 100644 index 0000000000000000000000000000000000000000..cba344637e21ede7915d11343509fba9d31a6f0c --- /dev/null +++ b/inventory_provider/routes/poller.py @@ -0,0 +1,78 @@ +import json + +from flask import Blueprint, Response, jsonify +from lxml import etree +from inventory_provider import juniper +from inventory_provider.routes import common + +routes = Blueprint('poller-support-routes', __name__) + + +@routes.route('/interfaces/<hostname>', methods=['GET', 'POST']) +@common.require_accepts_json +def poller_interface_oids(hostname): + r = common.get_redis() + + netconf_string = r.hget(hostname, 'netconf') + if not netconf_string: + return Response( + response='no netconf available info for %r' % hostname, + status=404, + mimetype='text/html') + + snmp_data_string = r.hget(hostname, 'snmp-interfaces') + if not snmp_data_string: + return Response( + response='no snmp available info for %r' % hostname, + status=404, + mimetype='text/html') + + def _ifc_name(ifc): + if 'v4InterfaceName' in ifc: + return ifc['v4InterfaceName'] + if 'v6InterfaceName' in ifc: + return ifc['v6InterfaceName'] + assert False, 'sanity failure: no interface name found' + + snmp_indexes = dict([ + (_ifc_name(ifc), int(ifc['index'])) for ifc in + json.loads(snmp_data_string.decode('utf-8')) + ]) + + interfaces = list(juniper.list_interfaces( + etree.XML(netconf_string.decode('utf-8')))) + + if not interfaces: + return Response( + response='no interfaces found for %r' % hostname, + status=404, + mimetype='text/html') + + result = [] + for ifc in interfaces: + if not ifc['description']: + continue + + snmp_index = snmp_indexes.get(ifc['name'], None) + if not snmp_index: + continue + + ifc_data = { + 'name': ifc['name'], + 'snmp-index': snmp_index, + 'description': ifc['description'], + 'circuits': [] + } + + circuits = r.hget( + 'interface_services', '%s::%s' % (hostname, ifc['name'])) + if circuits: + ifc_data['circuits'] = [ + {'type': c['circuit_type'], 'id': c['id']} + for c in json.loads(circuits.decode('utf-8')) + + ] + + result.append(ifc_data) + + return jsonify(result) diff --git a/test/per_router/test_data_routes.py b/test/per_router/test_data_routes.py index d089f37c62710b20db1c4a74dc267b9391bf8545..ff1079e91f4251fbc98627dd4b5ca3d59b61e74e 100644 --- a/test/per_router/test_data_routes.py +++ b/test/per_router/test_data_routes.py @@ -52,7 +52,7 @@ class MockedRedis(object): @pytest.fixture def client_with_mocked_data(mocker, client): mocker.patch( - 'inventory_provider.routes.data.redis.StrictRedis', + 'inventory_provider.routes.common.redis.StrictRedis', MockedRedis) return client diff --git a/test/per_router/test_poller_routes.py b/test/per_router/test_poller_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..e24c63e3e059a0e92aa3cae29067ae847a6f2beb --- /dev/null +++ b/test/per_router/test_poller_routes.py @@ -0,0 +1,107 @@ +import json +import os + +import pytest +import jsonschema + +import inventory_provider + +TEST_DATA_DIRNAME = os.path.realpath(os.path.join( + inventory_provider.__path__[0], + "..", + "test", + "data")) + +DEFAULT_REQUEST_HEADERS = { + "Content-type": "application/json", + "Accept": ["application/json"] +} + + +class MockedRedis(object): + + db = None + + def __init__(self, *args, **kwargs): + if MockedRedis.db is None: + test_data_filename = os.path.join( + TEST_DATA_DIRNAME, + "router-info.json") + with open(test_data_filename) as f: + MockedRedis.db = json.loads(f.read()) + + def set(self, key, value): + MockedRedis.db[key] = value + + def hget(self, key, field): + if key == 'interface_services': + return json.dumps([ + {'circuit_type': 'service', 'id': 9}, + {'circuit_type': 'abc', 'id': 99}, + {'circuit_type': 'xyz', 'id': 999}, + {'circuit_type': 'service', 'id': 9999} + ]).encode('utf-8') + value = MockedRedis.db[key] + return value[field].encode('utf-8') + + def hgetall(self, key): + result = {} + for k, v in MockedRedis.db[key].items(): + result[k.encode('utf-8')] \ + = json.dumps(v).encode('utf-8') + return result + + def keys(self, *args, **kwargs): + return list([k.encode("utf-8") for k in MockedRedis.db.keys()]) + + +@pytest.fixture +def client_with_mocked_data(mocker, client): + mocker.patch( + 'inventory_provider.routes.common.redis.StrictRedis', + MockedRedis) + return client + + +def test_router_interfaces(router, client_with_mocked_data): + + interfaces_list_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "circuit": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "id": {"type": "integer"} + }, + "required": ["type", "id"], + "additionalProperties": False + } + }, + + "type": "array", + "items": { + "type": "object", + "properties": { + "circuits": { + "type": "array", + "items": {"$ref": "#/definitions/circuit"} + }, + "description": {"type": "string"}, + "name": {"type": "string"}, + "snmp-index": {"type": "integer"} + }, + "required": ["circuits", "description", "name", "snmp-index"], + "additionalProperties": False + } + } + + rv = client_with_mocked_data.post( + "/poller/interfaces/" + router, + headers=DEFAULT_REQUEST_HEADERS) + + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + jsonschema.validate(response, interfaces_list_schema) + assert response # at least shouldn't be empty diff --git a/test/test_classifier_routes.py b/test/test_classifier_routes.py index a01236f25f847ca6b3989ded0d8549f2c8e76bbe..39c263cc76b0b8cf188fa9af8035ca5a8a149010 100644 --- a/test/test_classifier_routes.py +++ b/test/test_classifier_routes.py @@ -49,15 +49,15 @@ def test_juniper_addresses(mocker, client): ] class MockedRedis(): - def __init__(self): + def __init__(self, *args, **kwargs): pass def get(self, ignored): return json.dumps(test_data).encode('utf-8') mocker.patch( - 'inventory_provider.routes.classifier.db.get_redis', - return_value=MockedRedis()) + 'inventory_provider.routes.common.redis.StrictRedis', + MockedRedis) response_schema = { "$schema": "http://json-schema.org/draft-07/schema#", @@ -93,7 +93,7 @@ def test_trap_metadata(mocker, client): } ] mocked_redis = mocker.patch( - "inventory_provider.routes.classifier.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_redis.return_value.hget.return_value = json.dumps(test_data)\ .encode("utf-8") diff --git a/test/test_external_inventory_routes.py b/test/test_external_inventory_routes.py index 32c72c8a1787bfddfd6530f4b272fed485753aeb..a05a114289ce7c78fe80ec491425bd75a1b6115d 100644 --- a/test/test_external_inventory_routes.py +++ b/test/test_external_inventory_routes.py @@ -9,7 +9,7 @@ DEFAULT_REQUEST_HEADERS = { def test_get_one_equipment_location(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hget = mocked_redis.return_value.hget dummy_data = { "absid": 1404, @@ -38,7 +38,7 @@ def test_get_one_equipment_location(mocker, client): def test_get_equipment_location(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hgetall = mocked_redis.return_value.hgetall rv = client.get( @@ -54,7 +54,7 @@ def test_get_equipment_location(mocker, client): def test_get_interface_info(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hgetall = mocked_redis.return_value.hgetall rv = client.get( @@ -70,7 +70,7 @@ def test_get_interface_info(mocker, client): def test_get_interface_info_for_equipment(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hscan_iter = mocked_redis.return_value.hscan_iter rv = client.get( @@ -86,7 +86,7 @@ def test_get_interface_info_for_equipment(mocker, client): def test_get_interface_info_for_equipment_and_interface(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hget = mocked_redis.return_value.hget rv = client.get( @@ -102,7 +102,7 @@ def test_get_interface_info_for_equipment_and_interface(mocker, client): def test_get_children(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hget = mocked_redis.return_value.hget rv = client.get( @@ -119,7 +119,7 @@ def test_get_children(mocker, client): def test_get_parents(mocker, client): mocked_redis = mocker.patch( - "inventory_provider.routes.opsdb.db.get_redis") + "inventory_provider.routes.common.get_redis") mocked_hget = mocked_redis.return_value.hget rv = client.get(