diff --git a/inventory_provider/routes/common.py b/inventory_provider/routes/common.py index 7a84df255501f5d225279d696b71747325bb0348..e1821391fdd386e9e3314777716d44130ea30beb 100644 --- a/inventory_provider/routes/common.py +++ b/inventory_provider/routes/common.py @@ -1,6 +1,10 @@ +from collections import OrderedDict import functools +import json import logging -from collections import OrderedDict +import queue +import random +import threading import requests from flask import request, Response, current_app, g @@ -103,3 +107,92 @@ def after_request(response): data, str(response.status_code))) return response + + +def _redis_client_proc(key_queue, value_queue, config_params): + """ + create a local redis connection with the current db index, + lookup the values of the keys that come from key_queue + and put them o=n value_queue + + i/o contract: + None arriving on key_queue means no more keys are coming + put None in value_queue means we are finished + + :param key_queue: + :param value_queue: + :param config_params: app config + :return: yields dicts like {'key': str, 'value': dict} + """ + try: + r = tasks_common.get_current_redis(config_params) + while True: + key = key_queue.get() + + # contract is that None means no more requests + if not key: + break + + value = r.get(key).decode('utf-8') + value_queue.put({ + 'key': key, + 'value': json.loads(value) + }) + + except json.JSONDecodeError: + logger.exception(f'error decoding entry for {key}') + + finally: + # contract is to return None when finished + value_queue.put(None) + + +def load_json_docs(config_params, key_pattern, num_threads=10): + """ + load all json docs from redis + + the loading is done with multiple connections in parallel, since this + method is called from an api handler and when the client is far from + the redis master the cumulative latency causes nginx/gunicorn timeouts + + :param config_params: app config + :param pattern: key pattern to load + :param num_threads: number of client threads to create + :return: yields dicts like {'key': str, 'value': dict} + """ + response_queue = queue.Queue() + + threads = [] + for _ in range(num_threads): + q = queue.Queue() + t = threading.Thread( + target=_redis_client_proc, + args=[q, response_queue, config_params]) + t.start() + threads.append({'thread': t, 'queue': q}) + + r = tasks_common.get_current_redis(config_params) + # scan with bigger batches, to mitigate network latency effects + for k in r.scan_iter(key_pattern, count=1000): + k = k.decode('utf-8') + t = random.choice(threads) + t['queue'].put(k) + + # tell all threads there are no more keys coming + for t in threads: + t['queue'].put(None) + + num_finished = 0 + # read values from response_queue until we receive + # None len(threads) times + while num_finished < len(threads): + value = response_queue.get() + if not value: + num_finished += 1 + logger.debug('one worker thread finished') + continue + yield value + + # cleanup like we're supposed to, even though it's python + for t in threads: + t['thread'].join(timeout=0.5) # timeout, for sanity diff --git a/inventory_provider/routes/data.py b/inventory_provider/routes/data.py index f5f780cebb9365eec28c8af7b0524931c78d0fba..f25bd80dae5374c1b43b3802e697a07327b6c137 100644 --- a/inventory_provider/routes/data.py +++ b/inventory_provider/routes/data.py @@ -27,23 +27,41 @@ def routers(): return jsonify(result) +@routes.route("/interfaces", methods=['GET', 'POST']) @routes.route("/interfaces/<hostname>", methods=['GET', 'POST']) @common.require_accepts_json -def router_interfaces(hostname): - r = common.get_current_redis() - interfaces = [] - for k in r.keys('netconf-interfaces:%s:*' % hostname): - ifc = r.get(k.decode('utf-8')) - if ifc: - interfaces.append(json.loads(ifc.decode('utf-8'))) +def router_interfaces(hostname=None): - if not interfaces: - return Response( - response="no available interface info for '%s'" % hostname, - status=404, - mimetype="text/html") + cache_key = f'classifier-cache:netconf-interfaces:{hostname}' \ + if hostname else 'classifier-cache:netconf-interfaces:all' - return jsonify(interfaces) + r = common.get_current_redis() + result = r.get(cache_key) + if result: + result = result.decode('utf-8') + + else: + key_pattern = f'netconf-interfaces:{hostname}:*' \ + if hostname else 'netconf-interfaces:*' + config = current_app.config['INVENTORY_PROVIDER_CONFIG'] + + result = [] + for ifc in common.load_json_docs(config, key_pattern): + key_fields = ifc['key'].split(':') + ifc['value']['router'] = key_fields[1] + result.append(ifc['value']) + + if not result: + return Response( + response="no available interface info for '%s'" % hostname, + status=404, + mimetype="text/html") + + result = json.dumps(result) + # cache this data for the next call + r.set(cache_key, result.encode('utf-8')) + + return Response(result, mimetype="application/json") @routes.route("/pop/<equipment_name>", methods=['GET', 'POST']) diff --git a/inventory_provider/static/interfaces.html b/inventory_provider/static/interfaces.html index 80ca1c39c1566406bc9acd27198ea2f4a15c4cf9..e4c59af9ef7a17d4d536de2eb833e69d291a8b0e 100644 --- a/inventory_provider/static/interfaces.html +++ b/inventory_provider/static/interfaces.html @@ -9,18 +9,19 @@ <body> <div ng-controller="interfaces"> - <h2>Interfaces</h2> - <div> - <select - ng-options="r for r in routers" - ng-change="update_interfaces()" - ng-model="router"></select> - <select - ng-options="i for i in interfaces" - ng-change="update_status()" - ng-model="interface"> - </select> - </div> + <div class="column"> + <p><strong>interfaces</strong></p> + <ul> + <li ng-repeat="i in interfaces">{{i.router}}:{{i.name}} + <ul> + <li>{{i.description}}</li> + <li ng-repeat="v4 in i.ipv4">v4: {{v4}}</li> + <li ng-repeat="v6 in i.ipv6">v6: {{v6}}</li> + </ul> + </li> + </ul> + <!--div class="raw">{{interfaces}}</div--> + </div> <div> STATUS: {{status}} diff --git a/inventory_provider/static/interfaces.js b/inventory_provider/static/interfaces.js index 499350b2567bc0295ad6576d283359bdff6c1912..63ab9939fdf58148bd49631002ff40552d966c28 100644 --- a/inventory_provider/static/interfaces.js +++ b/inventory_provider/static/interfaces.js @@ -12,12 +12,13 @@ myApp.controller('interfaces', function($scope, $http) { $http({ method: 'GET', - url: window.location.origin + "/data/routers" + url: window.location.origin + "/data/interfaces" }).then( - function(rsp) {$scope.routers = rsp.data;}, + function(rsp) {$scope.interfaces = rsp.data;}, function(rsp) {$scope.routers = ['error'];} ); + /* $scope.update_interfaces = function() { $http({ @@ -47,5 +48,6 @@ myApp.controller('interfaces', function($scope, $http) { function(rsp) {$scope.interfaces = 'query error';} ); } + */ }); \ No newline at end of file diff --git a/inventory_provider/static/style.css b/inventory_provider/static/style.css index a72ff44af879d3d178cce21668484bca5bae43fa..b234cbe2d3b0a09e2a4a2efef909588cd0ddedc1 100644 --- a/inventory_provider/static/style.css +++ b/inventory_provider/static/style.css @@ -1,6 +1,6 @@ .column { float: left; - width: 33.33%; + width: 100%%; } /* Clear floats after the columns */ diff --git a/test/per_router/test_data_routes.py b/test/per_router/test_data_routes.py index 063aa2a3e13b49ff9df6fc3d07953edfbaa4806c..85fa9bae5600f70bca58615dd67b117f33145c62 100644 --- a/test/per_router/test_data_routes.py +++ b/test/per_router/test_data_routes.py @@ -18,6 +18,7 @@ def test_router_interfaces(router, client): "properties": { "name": {"type": "string"}, "description": {"type": "string"}, + "router": {"type": "string"}, "bundle": { "type": "array", "items": {"type": "string"} @@ -31,7 +32,7 @@ def test_router_interfaces(router, client): "items": {"type": "string"} } }, - "required": ["name", "description", "ipv4", "ipv6"], + "required": ["name", "description", "ipv4", "router", "ipv6"], "additionalProperties": False } } diff --git a/test/test_general_data_routes.py b/test/test_general_data_routes.py index fa8c385aa5866a4a77b583f43cb0c940831c676e..a618f8fb2c7ff6a0e01530511ce3fba166ef9a23 100644 --- a/test/test_general_data_routes.py +++ b/test/test_general_data_routes.py @@ -75,3 +75,42 @@ def test_pop_not_found(client, mocker): headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 404 + + +def test_router_interfaces_all(client): + + interfaces_list_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "router": {"type": "string"}, + "bundle": { + "type": "array", + "items": {"type": "string"} + }, + "ipv4": { + "type": "array", + "items": {"type": "string"} + }, + "ipv6": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["name", "description", "ipv4", "router", "ipv6"], + "additionalProperties": False + } + } + + rv = client.post( + '/data/interfaces', + 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_general_routes.py b/test/test_general_routes.py index a6a915e95e0412641280c155f09d0d31f519c90f..116e89f412329b2d053bcafe6531f55e12398e40 100644 --- a/test/test_general_routes.py +++ b/test/test_general_routes.py @@ -1,6 +1,8 @@ import json import jsonschema +from inventory_provider.routes import common + DEFAULT_REQUEST_HEADERS = { "Content-type": "application/json", "Accept": ["application/json"] @@ -50,3 +52,46 @@ def test_version_request(client, mocked_redis): jsonschema.validate( json.loads(rv.data.decode("utf-8")), version_schema) + + +def test_load_json_docs(data_config, mocked_redis): + + INTERFACE_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "interface": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "bundle": { + "type": "array", + "items": {"type": "string"} + }, + "ipv4": { + "type": "array", + "items": {"type": "string"} + }, + "ipv6": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["name", "description", "ipv4", "ipv6"], + "additionalProperties": False + } + }, + + "type": "object", + "properties": { + "key": {"type": "string"}, + "value": {"$ref": "#/definitions/interface"} + }, + "required": ["key", "value"], + "additionalProperties": False + } + + for ifc in common.load_json_docs( + data_config, 'netconf-interfaces:*', num_threads=20): + jsonschema.validate(ifc, INTERFACE_SCHEMA)