diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py index 3c9276b11ffa5857c2a7835c326d419a00a862e4..cff87797f0bf6281c9116f9c1a5750df26f558cd 100644 --- a/inventory_provider/routes/classifier.py +++ b/inventory_provider/routes/classifier.py @@ -38,19 +38,34 @@ These endpoints are intended for use by Dashboard V3. -------------------------------- .. autofunction:: inventory_provider.routes.classifier.get_mtc_interface_info + + +/classifier/router-info +-------------------------------- + +.. autofunction:: inventory_provider.routes.classifier.get_all_routers + + +/classifier/router-info/<equimpent-name> +-------------------------------- + +.. autofunction:: inventory_provider.routes.classifier.get_router_info + """ + +import functools import ipaddress import json import logging import re from functools import lru_cache -from typing import Optional +from typing import Iterable, List, Optional -from flask import Blueprint, Response, request +from flask import Blueprint, Response, jsonify, request from redis import Redis from inventory_provider.routes import common -from inventory_provider.routes.common import _ignore_cache_or_retrieve +from inventory_provider.routes.common import _ignore_cache_or_retrieve, cache_result routes = Blueprint("inventory-data-classifier-support-routes", __name__) @@ -137,22 +152,39 @@ def after_request(resp): @lru_cache(256) -def get_ims_equipment_name(equipment_name: str, r: Redis = None) -> str: - if not r: - r = common.get_current_redis() +def get_ims_equipment_name(equipment_name: str, redis: Redis = None) -> str: + redis = redis or common.get_current_redis() + candidates = _get_equipment_names_candidates(equipment_name) + result = _get_candidate_or_none(candidates, redis) + return result or candidates[0] + + +# We can't lru_cache this function like the above one, since if new equipment is +# installed and queried before the inventory is updated, it may return None. Then that +# result would be cached, and it would still not recognize that equipment, even after +# the inventory is updated +def get_ims_equipment_name_or_none( + equipment_name: str, redis: Redis = None +) -> Optional[str]: + redis = redis or common.get_current_redis() + candidates = _get_equipment_names_candidates(equipment_name) + return _get_candidate_or_none(candidates, redis) + + +def _get_candidate_or_none(candidates: Iterable[str], r: Redis) -> Optional[str]: + for c in candidates: + if r.exists(f"ims:location:{c}"): + return c + return None + + +def _get_equipment_names_candidates(equipment_name) -> List[str]: ims_equipment_name = equipment_name.upper() - candidates = [ - ims_equipment_name.split('.GEANT.')[0], + return [ + ims_equipment_name.split(".GEANT.")[0], ims_equipment_name, - ims_equipment_name.split('.OFFICE.')[0] + ims_equipment_name.split(".OFFICE.")[0], ] - return_value = candidates[0] - loc_key = 'ims:location:{}' - for c in candidates: - if r.exists(loc_key.format(c)): - return_value = c - break - return return_value def get_ims_interface(interface: str) -> str: @@ -1120,3 +1152,75 @@ def _get_coriant_info( r.set(cache_key, result.encode('utf-8')) return Response(result, mimetype="application/json") + + +@routes.route("/router-info", methods=["GET"]) +@common.require_accepts_json +def get_all_routers() -> Response: + redis = common.get_current_redis() + all_routers_raw = redis.get("netdash") + all_routers = json.loads(all_routers_raw) if all_routers_raw else {} + return jsonify( + [ + {"hostname": hostname, "vendor": vendor} + for hostname, vendor in all_routers.items() + ] + ) + + +@routes.route("/router-info/<equipment_name>", methods=["GET"]) +@common.require_accepts_json +def get_router_info(equipment_name: str) -> Response: + redis = common.get_current_redis() + ims_equipment_name = get_ims_equipment_name_or_none(equipment_name, redis) + if not ims_equipment_name: + return Response( + response=f"no router info available for {equipment_name}", + status=404, + mimetype="text/plain", + ) + + cache_key = f"classifier-cache:router:{ims_equipment_name}" + + result = cache_result( + cache_key, + func=functools.partial(_get_router_info, ims_equipment_name, redis), + redis=redis, + ) + return Response(result, mimetype="application/json") + + +def _get_router_info(ims_source_equipment, redis): + + def _format_service(service: dict): + keys = {"name", "status", "service_type", "sid"} + return {k: v for k, v in service.items() if k in keys} + + related_services = {} + contacts = set() + + vendor = "unknown" + all_routers = redis.get("netdash") + + for name, v in (json.loads(all_routers) if all_routers else {}).items(): + if name.startswith(ims_source_equipment.lower()): + vendor = v + break + + for key in redis.scan_iter(f"ims:interface_services:{ims_source_equipment}:*"): + raw_services = redis.get(key.decode()) + if not raw_services: + continue + + services = json.loads(raw_services) + for service in services: + related_services.update({r["id"]: r for r in service["related-services"]}) + contacts.update(service.get("contacts", [])) + return { + "related-services": sorted( + map(_format_service, related_services.values()), key=lambda s: s["name"] + ), + "contacts": sorted(contacts), + "location": _location_from_equipment(ims_source_equipment, redis), + "vendor": vendor, + } diff --git a/inventory_provider/routes/classifier_schema.py b/inventory_provider/routes/classifier_schema.py index 88a104ad5a1e5ccf35929dd126925ce48d277c74..1b289c0824c2df02105e85b8166e54067cbd88f1 100644 --- a/inventory_provider/routes/classifier_schema.py +++ b/inventory_provider/routes/classifier_schema.py @@ -752,3 +752,57 @@ TNMS_FIBERLINK_INFO_RESPONSE_SCHEMA = { 'locations', 'ends', 'df_route', 'related-services', 'contacts'], 'additionalProperties': False } + +ROUTER_INFO_RESPONSE_SCHEMA = { + "$schema": "https://json-schema.org/draft-07/schema#", + "definitions": { + "related-service-info": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "status": {"$ref": "#/definitions/status"}, + "service_type": {"type": "string"}, + "sid": {"type": "string"}, + }, + "required": ["name", "status", "service_type"], + "additionalProperties": False, + }, + "status": { + "type": "string", + "enum": [ + "planned", + "installed", + "operational", + "terminated", + "disposed", + "non-monitored", + ], + }, + **_common_locations_schema_definitions, + }, + "type": "object", + "properties": { + "related-services": { + "type": "array", + "items": {"$ref": "#/definitions/related-service-info"}, + }, + "contacts": {"type": "array", "items": {"type": "string"}}, + "location": {"$ref": "#/definitions/location-endpoint"}, + "vendor": {"type": "string"}, + }, + "required": ["related-services", "contacts", "location", "vendor"], + "additionalProperties": False, +} +ROUTER_INFO_ALL_ROUTERS_RESPONSE_SCHEMA = { + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "array", + "items": { + "type": "object", + "properties": { + "hostname": {"type": "string"}, + "vendor": {"type": "string", "enum": ["juniper", "nokia", "unknown"]}, + }, + "required": ["hostname", "vendor"], + "additionalProperties": False, + }, +} diff --git a/inventory_provider/routes/common.py b/inventory_provider/routes/common.py index c74e5dfea1e2c0ff926db8764004f2b132727e91..3f37d0b492ffed139a96ff0c4f51ede63b37ac33 100644 --- a/inventory_provider/routes/common.py +++ b/inventory_provider/routes/common.py @@ -8,6 +8,7 @@ import re import threading from distutils.util import strtobool +from typing import Optional from lxml import etree import requests from flask import request, Response, current_app, g @@ -28,10 +29,35 @@ def get_bool_request_arg(name, default=False): return value +def cache_result(key, func, redis) -> Optional[str]: + """Get the result one way or the other, first check in the cache and return it + if it exists. If it doesn't, or ignore-cache is active, it will obtain the + result from ``func``, a callable that takes zero arguments and returns + the result. If this result is not None, the result will then be cached. The final + result will be a json encoded string (or None). + + :param key: a redis key + :param func: a callable that takes zero arguments and returns a dict or other + json-encodable object + :param redis: a Redis instance + + :return: a str with the json-encoded result data + """ + result = _ignore_cache_or_retrieve(None, key, redis) + if result is not None: + return result + data = func() + if data is not None: + data = json.dumps(data) + # cache this data for the next call + redis.set(key, data.encode()) + return data + + def _ignore_cache_or_retrieve(request_, cache_key, r): ignore_cache = get_bool_request_arg('ignore-cache', default=False) if ignore_cache: - result = False + result = None logger.debug('ignoring cache') else: result = r.get(cache_key) diff --git a/test/conftest.py b/test/conftest.py index bd976e63f94394dc9e9207c0583ac5476c72aa95..ac2a913df5729570d4bc199f45bacbc50f7d85cd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -260,6 +260,7 @@ def mocked_redis(mocker): mocker.patch( 'inventory_provider.tasks.common.redis.StrictRedis', return_value=instance) + return instance @pytest.fixture diff --git a/test/test_classifier_routes.py b/test/test_classifier_routes.py index aee43eac7fc00ded461b5c2bc7cecf7cf6e9cacd..a3f485d8eb2a268f996e1ac71d7f09c903f2ce5a 100644 --- a/test/test_classifier_routes.py +++ b/test/test_classifier_routes.py @@ -2,11 +2,17 @@ import json import jsonschema import pytest -from inventory_provider.routes.classifier_schema \ - import JUNIPER_LINK_RESPONSE_SCHEMA, PEER_INFO_RESPONSE_SCHEMA, \ - INFINERA_LAMBDA_INFO_RESPONSE_SCHEMA, CORIANT_INFO_RESPONSE_SCHEMA, \ - INFINERA_FIBERLINK_INFO_RESPONSE_SCHEMA, \ - MTC_INTERFACE_INFO_RESPONSE_SCHEMA, TNMS_FIBERLINK_INFO_RESPONSE_SCHEMA +from inventory_provider.routes.classifier_schema import ( + JUNIPER_LINK_RESPONSE_SCHEMA, + PEER_INFO_RESPONSE_SCHEMA, + INFINERA_LAMBDA_INFO_RESPONSE_SCHEMA, + CORIANT_INFO_RESPONSE_SCHEMA, + INFINERA_FIBERLINK_INFO_RESPONSE_SCHEMA, + MTC_INTERFACE_INFO_RESPONSE_SCHEMA, + ROUTER_INFO_ALL_ROUTERS_RESPONSE_SCHEMA, + ROUTER_INFO_RESPONSE_SCHEMA, + TNMS_FIBERLINK_INFO_RESPONSE_SCHEMA, +) DEFAULT_REQUEST_HEADERS = { "Content-type": "application/json", @@ -219,3 +225,42 @@ def test_tnms_fibre_info(client): assert rv.is_json response_data = json.loads(rv.data.decode('utf-8')) jsonschema.validate(response_data, TNMS_FIBERLINK_INFO_RESPONSE_SCHEMA) + + +def test_router_info_all_routers(client): + rv = client.get("/classifier/router-info", headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + assert rv.is_json + result = rv.json + + jsonschema.validate(result, ROUTER_INFO_ALL_ROUTERS_RESPONSE_SCHEMA) + assert len(result) > 0 + + +@pytest.mark.parametrize("router", ["mx1.ams.nl", "mx1.ams.nl.geant.net"]) +def test_router_info(client, router): + rv = client.get( + f"/classifier/router-info/{router}", headers=DEFAULT_REQUEST_HEADERS + ) + assert rv.status_code == 200 + assert rv.is_json + result = rv.json + jsonschema.validate(result, ROUTER_INFO_RESPONSE_SCHEMA) + assert len(result['related-services']) > 0 + assert len(result['contacts']) > 0 + + +def test_router_info_caches_result(client, mocked_redis): + router = "mx1.ams.nl" + cache_key = f"classifier-cache:router:{router.upper()}" + assert not mocked_redis.exists(cache_key) + client.get(f"/classifier/router-info/{router}", headers=DEFAULT_REQUEST_HEADERS) + assert mocked_redis.exists(cache_key) + + +def test_404_for_nonexistent_router(client): + router = "invalid.ams.nl" + rv = client.get( + f"/classifier/router-info/{router}", headers=DEFAULT_REQUEST_HEADERS + ) + assert rv.status_code == 404