diff --git a/MANIFEST.in b/MANIFEST.in index be4a825561e7c94813eaf2b9db2cab18f9ba7cb8..a8495446b771f87686bf6e09307093d7edd9e5d6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ recursive-include inventory_provider/static * +include inventory_provider/logging_default_config.json diff --git a/README.md b/README.md index 93ef44eea42beb5978bfc3bef875c0abf6f8551e..774e59d40a7f1d8830855cb1547a71fdfa01a6a3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -1. [Inventory Provider] - 1. [Overview] - 2. [Configuration] - 3. [Running this module] - 4. [Protocol specification] - 5. [backend (Redis) storage schema] +* [Inventory Provider](#inventory-provider) + * [Overview](#overview) + * [Configuration](#configuration) + * [Running this module](#running-this-module) + * [Protocol Specification](#protocol-specification) + * [Backend (Redis) Storage Schema](#backend-redis-storage-schema) @@ -42,18 +42,19 @@ The following is an example: ```python INVENTORY_PROVIDER_CONFIG_FILENAME = "/somepath/config.json" +ENABLE_TESTING_ROUTES = True ``` -- `INVENTORY_PROVIDER_CONFIG_FILENAME`: run-time accessible filename +- `INVENTORY_PROVIDER_CONFIG_FILENAME`: [REQUIRED] Run-time accessible filename of a json file containing the server configuration parameters. This file must be formatted according to the following json schema: ```json { "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "alarms-db": { + + "definitions": { + "database_credentials": { "type": "object", "properties": { "hostname": {"type": "string"}, @@ -63,12 +64,19 @@ must be formatted according to the following json schema: }, "required": ["hostname", "dbname", "username", "password"], "additionalProperties": False - }, + + } + }, + + "type": "object", + "properties": { + "alarms-db": {"$ref": "#/definitions/database_credentials"}, + "ops-db": {"$ref": "#/definitions/database_credentials"}, "oid_list.conf": {"type": "string"}, - "routers_community.conf": {"type": "string"}, "ssh": { "type": "object", "properties": { + "username": {"type": "string"}, "private-key": {"type": "string"}, "known-hosts": {"type": "string"} }, @@ -83,19 +91,57 @@ must be formatted according to the following json schema: }, "required": ["hostname", "port"], "additionalProperties": False + }, + "junosspace": { + "api": {"type": "string"}, + "username": {"type": "string"}, + "password": {"type": "string"} + }, + "infinera-dna": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"type": "string"} + }, + "required": ["name", "address"], + "additionalProperties": False + } + }, + "coriant-tnms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"type": "string"} + }, + "required": ["name", "address"], + "additionalProperties": False + } } }, "required": [ "alarms-db", + "ops-db", "oid_list.conf", - "routers_community.conf", "ssh", - "redis"], + "redis", + "junosspace", + "infinera-dna", + "coriant-tnms"], "additionalProperties": False } ``` +- `ENABLE_TESTING_ROUTES`: [OPTIONAL (default value: False)] +Flat (can be any value that evaluates to True) to enable +routes to special utilities used for testing. +*This must never be enabled in a production environment.* + + ## Running this module This module has been tested in the following execution environments: @@ -111,10 +157,14 @@ $ flask run - As an Apache/`mod_wsgi` service. - Details of Apache and `mod_wsgi` -configuration are beyond the scope of this document. + configuration are beyond the scope of this document. +- As a `gunicorn` wsgi service. + - Details of `gunicorn` configuration are + beyond the scope of this document. -## protocol specification + +## Protocol Specification The following resources can be requested from the webservice. @@ -253,52 +303,58 @@ Any non-empty responses are JSON formatted messages. * /jobs/update This resource updates the inventory network data for juniper devices. + The function completes asynchronously and a list of outstanding + task id's is returned so the caller can + use `/jobs/check-task-status` to determine when all jobs + are finished. The response will be formatted as follows: + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": {"type": "string"} + } + ``` -* /jobs/update-startup +* /jobs/reload-router-config/<equipment-name> - This resource updates data that should only be refreshed - in case of system restart. - -* /jobs/update-interfaces-to-services + This resource updates the inventory network data for + the identified juniper device. This function completes + asynchronously and returns the same value as + `/jobs/update`, except the return contains exactly + one task id. - This resource updates the information that lists all the services for - interfaces found in the external inventory system - -* /jobs/update-service-hierarchy +* /jobs/check-task-status/<task-id>" - This resource updates all the information showing which services are related - to one-another - -* /jobs/update-equipment-locations + This resource returns the current status of + an asynchronous task started by `/jobs/update` + or `jobs/reload-router-config`. The return value + will be formatted as follows: + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": {"type": "string"}, + "status": {"type": "string"}, + "exception": {"type": "boolean"}, + "ready": {"type": "boolean"}, + "success": {"type": "boolean"}, + "result": {"type": "object"} + }, + "required": ["id", "status", "exception", "ready", "success"], + "additionalProperties": False + } + ``` - This resource loads the location information for all the equipment from the - external inventory system - -* /jobs/update-from-inventory-system - - This resource updates the inventory data from the external inventory system, - it is a short cut for executing the above three jobs - * /jobs/update-interface-statuses This resource updates the last known statuses of all interfaces extracted from the external inventory system, status information is retrieved from the alarms database -* /classifier/infinera-dna-addresses, /classifier/juniper-server-addresses - Both of these resources return lists of source addresses - of known senders of snmp traps. Responses will be - formatted as follows: - - ```json - { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "array", - "items": {"type": "string"} - } - ``` - * /classifier/*`type`*/*`source-equipment`*/*`source-interface`* The source-equipment is the equipment that causes the trap, not the NMS that @@ -356,15 +412,44 @@ Any non-empty responses are JSON formatted messages. } ``` -## backend (Redis) storage schema -`netconf:<hostname>` +### Testing utilities + +The following routes are only available if the server +was started with the `ENABLE_TESTING_ROUTES` flag. + + +* `/testing/flushdb` + + This method erases all data in the backend redis + database. + +* /testing/infinera-dna-addresses, /testing/coriant-tnmp-addresses, /testing/juniper-server-addresses` + + All of these resources return lists of source addresses + of known senders of snmp traps. Responses will be + formatted as follows: + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": {"type": "string"} + } + ``` + + +## Backend (Redis) Storage Schema + +* `netconf:<hostname>` + * key example * `netconf:mx1.ams.nl.geant.net` * value format * cf. validation in `inventory_provider.juniper.load_config` -`snmp-interfaces:<hostname>` +* `snmp-interfaces:<hostname>` + * key example: * `snmp-interfaces:mx1.lon2.uk.geant.net` * value schema @@ -379,20 +464,20 @@ Any non-empty responses are JSON formatted messages. "properties": { "v4Address": { "type": "string", - "pattern": "^(\d+\.){3}\d+$" + "pattern": r'^(\d+\.){3}\d+$' }, "v4Mask": { "type": "string", - "pattern": "^(\d+\.){3}\d+$" + "pattern": r'^(\d+\.){3}\d+$' }, - "v4InterfaceName": {:"type", "string"}, - "index": {` + "v4InterfaceName": {"type": "string"}, + "index": { "type": "string", - "pattern": "^\d+$" + "pattern": r'^\d+$' } }, "required": [ - "v4Address", "v4Mask", "v4InterfaceName", "index], + "v4Address", "v4Mask", "v4InterfaceName", "index"], "additionalProperties": False }, "v6ifc": { @@ -400,36 +485,36 @@ Any non-empty responses are JSON formatted messages. "properties": { "v6Address": { "type": "string", - "pattern": "^[\d:]+$" + "pattern": r'^[a-f\d:]+$' }, "v6Mask": { "type": "string", - "pattern": "^\d+$" + "pattern": r'^\d+$' }, - "v6InterfaceName": {:"type", "string"}, - "index": {` + "v6InterfaceName": {"type": "string"}, + "index": { "type": "string", - "pattern": "^\d+$" + "pattern": r'^\d+$' } }, "required": [ - "v6Address", "v6Mask", "v6InterfaceName", "index], + "v6Address", "v6Mask", "v6InterfaceName", "index"], "additionalProperties": False } }, "type": "array", "items": { - "anyOf": { - "$ref": "#/definitions/v4Ifc", - "$ref": "#/definitions/v6Ifc" - } + "anyOf": [ + {"$ref": "#/definitions/v4ifc"}, + {"$ref": "#/definitions/v6ifc"} + ] } } ``` -`opsdb:interface_services:<equipment name>:<interface name>` +* `opsdb:interface_services:<equipment name>:<interface name>` * key examples * `opsdb:interface_services:mx1.ams.nl.geant.net:ae15.1103` @@ -504,7 +589,7 @@ Any non-empty responses are JSON formatted messages. ``` -`opsdb:location:<equipment name>` +* `opsdb:location:<equipment name>` * key examples * `opsdb:location:mx1.ams.nl.geant.net` @@ -535,8 +620,8 @@ Any non-empty responses are JSON formatted messages. } ``` -`opsdb:services:children:<circuit db id>` -`opsdb:services:parents:<circuit db id>` +* `opsdb:services:children:<circuit db id>` +* `opsdb:services:parents:<circuit db id>` * key examples * `opsdb:services:children:12363` @@ -588,7 +673,7 @@ Any non-empty responses are JSON formatted messages. } ``` -`alarmsdb:interface_status:<equipment name>:<interface name>` +* `alarmsdb:interface_status:<equipment name>:<interface name>` * key examples * `alarmsdb:interface_status:Lab node 1:1-1/3` * `alarmsdb:interface_status:mx1.ams.nl.geant.net:ae15.1500` @@ -599,7 +684,7 @@ Any non-empty responses are JSON formatted messages. * `down` -`ix_public_peer:<address>` +* `ix_public_peer:<address>` * key examples * `ix_public_peer:193.203.0.203` * `ix_public_peer:2001:07f8:00a0:0000:0000:5926:0000:0002` @@ -637,7 +722,7 @@ Any non-empty responses are JSON formatted messages. } ``` -`vpn_rr_peers/<address>` +* `vpn_rr_peers/<address>` * key examples * `ix_public_peer:193.203.0.203` * valid values: @@ -665,3 +750,55 @@ Any non-empty responses are JSON formatted messages. "additionalProperties": False } ``` + +* `reverse_interface_addresses/<address>` + * key examples + * `reverse_interface_addresses:193.203.0.203` + * `reverse_interface_addresses:2001:07f8:00a0:0000:0000:5926:0000:0002` + * valid values: + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "v4a": { + "type": "string", + "pattern": r'^(\d+\.){3}\d+$' + }, + "v6a": { + "type": "string", + "pattern": r'^([a-f\d]{4}:){7}[a-f\d]{4}$' + }, + "v4i": { + "type": "string", + "pattern": r'^(\d+\.){3}\d+/\d+$' + }, + "v6i": { + "type": "string", + "pattern": r'^[a-f\d:]+/\d+$' + } + }, + + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "oneOf": [ + {"$ref": "#/definitions/v4a"}, + {"$ref": "#/definitions/v6a"} + ] + }, + "interface address": { + "oneOf": [ + {"$ref": "#/definitions/v4i"}, + {"$ref": "#/definitions/v6i"} + ] + }, + "interface name": {"type": "string"}, + }, + "required": ["name", "interface address", "interface name"], + "additionalProperties": False + } + } + ``` diff --git a/changelog b/changelog index c5d29d948e99d30748ddd49ad8a061404964b093..348bc6ac1eaa3a2182fcc12432df42ed9a06a664 100644 --- a/changelog +++ b/changelog @@ -25,3 +25,5 @@ read snmp community string from netconf derive active router list from junosspace cache ix public & vpn rr peers + use external logging config file + added utilities for the test environment diff --git a/inventory_provider/__init__.py b/inventory_provider/__init__.py index 485cf81ce4288c4d353520c2245104d0fe839e2d..024fac841eb30cde1e859612856db2bc2b1e1293 100644 --- a/inventory_provider/__init__.py +++ b/inventory_provider/__init__.py @@ -14,6 +14,10 @@ def create_app(): :return: a new flask app instance """ + if "SETTINGS_FILENAME" not in os.environ: + assert False, \ + "environment variable SETTINGS_FILENAME' must be defined" + app = Flask(__name__) app.secret_key = "super secret session key" @@ -26,18 +30,14 @@ def create_app(): from inventory_provider.routes import jobs app.register_blueprint(jobs.routes, url_prefix='/jobs') - from inventory_provider.routes import opsdb - app.register_blueprint(opsdb.routes, url_prefix='/opsdb') - 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" + logging.info("initializing Flask with config from: %r" + % os.environ["SETTINGS_FILENAME"]) app.config.from_envvar("SETTINGS_FILENAME") assert "INVENTORY_PROVIDER_CONFIG_FILENAME" in app.config, ( @@ -48,11 +48,18 @@ def create_app(): "config file '%s' not found" % app.config["INVENTORY_PROVIDER_CONFIG_FILENAME"]) + if app.config.get('ENABLE_TESTING_ROUTES', False): + from inventory_provider.routes import testing + app.register_blueprint(testing.routes, url_prefix='/testing') + logging.warning('DANGER!!! testing routes enabled') + from inventory_provider import config with open(app.config["INVENTORY_PROVIDER_CONFIG_FILENAME"]) as f: # test the config file can be loaded + logging.info("loading config from: %r" + % app.config["INVENTORY_PROVIDER_CONFIG_FILENAME"]) app.config["INVENTORY_PROVIDER_CONFIG"] = config.load(f) - logging.debug(app.config) + logging.info('Inventory Provider Flask app initialized') return app diff --git a/inventory_provider/constants.py b/inventory_provider/constants.py deleted file mode 100644 index f88475574e7b44dd3d17116027a2ba6c8588a0a6..0000000000000000000000000000000000000000 --- a/inventory_provider/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -SNMP_LOGGER_NAME = "snmp-logger" -JUNIPER_LOGGER_NAME = "juniper-logger" -DATABASE_LOGGER_NAME = "database-logger" -TASK_LOGGER_NAME = "task-logger" diff --git a/inventory_provider/environment.py b/inventory_provider/environment.py index ad4bcadcf93f39e75e403fab6fd2545ff46284d9..989c0a1355ebb7b0e44f110842b25f045868a47d 100644 --- a/inventory_provider/environment.py +++ b/inventory_provider/environment.py @@ -1,26 +1,24 @@ -import logging +import json +import logging.config import os -import sys -from inventory_provider import constants - - -def _level_from_env(var_name, default_level=logging.INFO): - level_str = os.getenv(var_name, logging.getLevelName(default_level)) - numeric_level = getattr(logging, level_str.upper(), default_level) - logging.debug('setting %s logging level to %s' - % (var_name, logging.getLevelName(numeric_level))) - return numeric_level def setup_logging(): - logging.basicConfig( - stream=sys.stderr, - level=_level_from_env('DEFAULT_LOGGING', logging.INFO)) - logging.getLogger(constants.SNMP_LOGGER_NAME).setLevel( - _level_from_env('SNMP_LOGGING', logging.INFO)) - logging.getLogger(constants.TASK_LOGGER_NAME).setLevel( - _level_from_env('TASK_LOGGING', logging.INFO)) - logging.getLogger(constants.JUNIPER_LOGGER_NAME).setLevel( - _level_from_env('JUNIPER_LOGGING', logging.INFO)) - logging.getLogger(constants.DATABASE_LOGGER_NAME).setLevel( - _level_from_env('DATABASE_LOGGING', logging.INFO)) + """ + set up logging using the configured filename + + if LOGGING_CONFIG is defined in the environment, use this for + the filename, otherwise use logging_default_config.json + """ + default_filename = os.path.join( + os.path.dirname(__file__), + 'logging_default_config.json') + filename = os.getenv('LOGGING_CONFIG', default_filename) + with open(filename) as f: + # TODO: this mac workaround should be removed ... + d = json.loads(f.read()) + import platform + if platform.system() == 'Darwin': + d['handlers']['syslog_handler']['address'] = '/var/run/syslog' + logging.config.dictConfig(d) + # logging.config.dictConfig(json.loads(f.read())) diff --git a/inventory_provider/juniper.py b/inventory_provider/juniper.py index 741ef352381f1b6213e9bfc936fcbe8ac0c3ee61..468eef553af60b98d553a4c9b756b1db5069536f 100644 --- a/inventory_provider/juniper.py +++ b/inventory_provider/juniper.py @@ -8,8 +8,6 @@ import netifaces import requests from requests.auth import HTTPBasicAuth -from inventory_provider.constants import JUNIPER_LOGGER_NAME - CONFIG_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> @@ -105,6 +103,11 @@ UNIT_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> </xs:schema> """ # noqa: E501 + +# elements 'use-nat' and 'fingerprint' were added between +# junosspace versions 15.x and 17.x ... hopefully new versions +# will also add new elements at the end of the sequence so +# that the final xs:any below will suffice to allow validation JUNOSSPACE_DEVICES_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> @@ -127,8 +130,7 @@ JUNOSSPACE_DEVICES_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> <xs:element name="domain-id" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="domain-name" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="config-status" minOccurs="1" maxOccurs="1" type="xs:string" /> - <xs:element name="use-nat" minOccurs="0" maxOccurs="1" type="xs:boolean" /> - <xs:element name="fingerprint" minOccurs="0" maxOccurs="1" type="xs:string" /> + <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" /> </xs:sequence> <xs:attribute name="href" type="xs:string" /> <xs:attribute name="uri" type="xs:string" /> @@ -159,13 +161,13 @@ def _rpc(hostname, ssh): def validate_netconf_config(config_doc): - juniper_logger = logging.getLogger(JUNIPER_LOGGER_NAME) + logger = logging.getLogger(__name__) def _validate(schema, doc): if schema.validate(doc): return for e in schema.error_log: - juniper_logger.error("%d.%d: %s" % (e.line, e.column, e.message)) + logger.error("%d.%d: %s" % (e.line, e.column, e.message)) assert False schema_doc = etree.XML(CONFIG_SCHEMA.encode('utf-8')) @@ -190,8 +192,8 @@ def load_config(hostname, ssh_params): :param ssh_params: 'ssh' config element(cf. config.py:CONFIG_SCHEMA) :return: """ - juniper_logger = logging.getLogger(JUNIPER_LOGGER_NAME) - juniper_logger.info("capturing netconf data for '%s'" % hostname) + logger = logging.getLogger(__name__) + logger.info("capturing netconf data for '%s'" % hostname) config = _rpc(hostname, ssh_params).get_config() validate_netconf_config(config) return config @@ -290,6 +292,22 @@ def vpn_rr_peers(netconf_config): neighbor['peer-as'] = int(r.find('peer-as').text) yield neighbor + +def interface_addresses(netconf_config): + """ + yields a list of all distinct interface addresses + :param netconf_config: + :return: + """ + for ifc in list_interfaces(netconf_config): + for address in ifc['ipv4'] + ifc['ipv6']: + yield { + "name": ipaddress.ip_interface(address).ip.exploded, + "interface address": address, + "interface name": ifc['name'] + } + + # note for enabling vrr data parsing ... # def fetch_vrr_config(hostname, ssh_params): # @@ -313,7 +331,7 @@ def load_routers_from_junosspace(config): :param config: junosspace config element from app config :return: list of dictionaries, each element of which describes a router """ - juniper_logger = logging.getLogger(JUNIPER_LOGGER_NAME) + logger = logging.getLogger(__name__) request_url = config['api'] if not request_url.endswith('/'): @@ -328,7 +346,7 @@ def load_routers_from_junosspace(config): ) # TODO: use a proper exception type if r.status_code != 200: - juniper_logger.error("error response from %r" % request_url) + logger.error("error response from %r" % request_url) assert False # TODO: use proper exception type devices = etree.fromstring(r.text.encode('utf-8')) @@ -336,7 +354,7 @@ def load_routers_from_junosspace(config): schema = etree.XMLSchema(schema_doc) if not schema.validate(devices): for e in schema.error_log: - juniper_logger.error("%d.%d: %s" % (e.line, e.column, e.message)) + logger.error('%d.%d: %s' % (e.line, e.column, e.message)) assert False for d in devices.xpath('//devices/device'): @@ -346,6 +364,8 @@ def load_routers_from_junosspace(config): if m: hostname = m.group(1) + '.geant.net' else: + logger.error( + 'unrecognized junosspace device name format :%s' % name) hostname = None yield { "OSVersion": d.xpath('./OSVersion/text()')[0], @@ -395,3 +415,17 @@ def snmp_community_string(netconf_config): if me in allowed_network: return community.xpath('./name/text()')[0] return None + + +def netconf_changed_timestamp(netconf_config): + ''' + return the last change timestamp published by the config document + :param netconf_config: netconf lxml etree document + :return: an epoch timestamp (integer number of seconds) or None + ''' + for ts in netconf_config.xpath('/configuration/@changed-seconds'): + if re.match(r'^\d+$', ts): + return int(ts) + logger = logging.getLogger(__name__) + logger.warning('no valid timestamp found in netconf configuration') + return None diff --git a/inventory_provider/logging_default_config.json b/inventory_provider/logging_default_config.json new file mode 100644 index 0000000000000000000000000000000000000000..ba3f1eb38510e597063d1d27ceddbea4c0286f10 --- /dev/null +++ b/inventory_provider/logging_default_config.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "simple": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + } + }, + + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "simple", + "stream": "ext://sys.stdout" + }, + + "syslog_handler": { + "class": "logging.handlers.SysLogHandler", + "level": "DEBUG", + "address": "/dev/log", + "facility": "user", + "formatter": "simple" + }, + + "info_file_handler": { + "class": "logging.handlers.RotatingFileHandler", + "level": "INFO", + "formatter": "simple", + "filename": "info.log", + "maxBytes": 10485760, + "backupCount": 20, + "encoding": "utf8" + }, + + "error_file_handler": { + "class": "logging.handlers.RotatingFileHandler", + "level": "ERROR", + "formatter": "simple", + "filename": "errors.log", + "maxBytes": 10485760, + "backupCount": 20, + "encoding": "utf8" + } + }, + + "loggers": { + "inventory_provider": { + "level": "INFO", + "handlers": ["console", "syslog_handler"], + "propagate": false + }, + "inventory_provider.tasks": { + "level": "DEBUG" + } + }, + + "root": { + "level": "WARNING", + "handlers": ["console", "syslog_handler"] + } +} \ No newline at end of file diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py index 43c8b35a4fd931aa48b6bdedb73aded5c601bf0e..0a9602b8a92c737005567918e1bb9983f09b166b 100644 --- a/inventory_provider/routes/classifier.py +++ b/inventory_provider/routes/classifier.py @@ -1,42 +1,12 @@ import json -from flask import Blueprint, Response, current_app, jsonify +from flask import Blueprint, Response from inventory_provider.routes import common routes = Blueprint("inventory-data-classifier-support-routes", __name__) -@routes.route("/infinera-dna-addresses", methods=['GET', 'POST']) -@common.require_accepts_json -def infinera_addresses(): - infinera_config = current_app.config[ - "INVENTORY_PROVIDER_CONFIG"]["infinera-dna"] - return jsonify([dna['address'] for dna in infinera_config]) - - -@routes.route("/coriant-tnms-addresses", methods=['GET', 'POST']) -@common.require_accepts_json -def coriant_addresses(): - coriant_config = current_app.config[ - "INVENTORY_PROVIDER_CONFIG"]["coriant-tnms"] - return jsonify([tnms['address'] for tnms in coriant_config]) - - -@routes.route("/juniper-server-addresses", methods=['GET', 'POST']) -@common.require_accepts_json -def juniper_addresses(): - # TODO: this route (and corant, infinera routes) can be removed - r = common.get_redis() - routers = [] - for k in r.keys('junosspace:*'): - info = r.get(k.decode('utf-8')) - assert info # sanity: value shouldn't be empty - info = json.loads(info.decode('utf-8')) - routers.append(info['address']) - return jsonify(routers) - - @routes.route("/trap-metadata/<source_equipment>/<path:interface>", methods=['GET', 'POST']) @common.require_accepts_json diff --git a/inventory_provider/routes/jobs.py b/inventory_provider/routes/jobs.py index c26ab6a588041272a855065d38d13e1d028b4474..02e778ab4b0bd8d4b85fc44ae79dd33c663701f8 100644 --- a/inventory_provider/routes/jobs.py +++ b/inventory_provider/routes/jobs.py @@ -1,23 +1,32 @@ -from flask import Blueprint, Response, current_app +from flask import Blueprint, Response, current_app, jsonify from inventory_provider.tasks import worker +from inventory_provider.routes import common routes = Blueprint("inventory-data-job-routes", __name__) @routes.route("/update", methods=['GET', 'POST']) +@common.require_accepts_json def update(): - worker.start_refresh_cache_all( + job_ids = worker.launch_refresh_cache_all( current_app.config["INVENTORY_PROVIDER_CONFIG"]) - return Response("OK") + return jsonify(job_ids) -@routes.route("update-interface-statuses") +@routes.route("update-interface-statuses", methods=['GET', 'POST']) def update_interface_statuses(): worker.update_interface_statuses.delay() return Response("OK") -@routes.route("reload-router-config/<equipment_name>") +@routes.route("reload-router-config/<equipment_name>", methods=['GET', 'POST']) +@common.require_accepts_json def reload_router_config(equipment_name): - worker.reload_router_config.delay(equipment_name) - return Response("OK") + result = worker.reload_router_config.delay(equipment_name) + return jsonify([result.id]) + + +@routes.route("check-task-status/<task_id>", methods=['GET', 'POST']) +@common.require_accepts_json +def check_task_status(task_id): + return jsonify(worker.check_task_status(task_id)) diff --git a/inventory_provider/routes/opsdb.py b/inventory_provider/routes/testing.py similarity index 54% rename from inventory_provider/routes/opsdb.py rename to inventory_provider/routes/testing.py index 4b7fba38a3ea89d7b03933ded6f5b145dab978c9..8d66aa5016c124ed9238a957116ac2a58c79cc4f 100644 --- a/inventory_provider/routes/opsdb.py +++ b/inventory_provider/routes/testing.py @@ -2,23 +2,49 @@ import collections import json import re -from flask import Blueprint, jsonify +from flask import Blueprint, Response, current_app, jsonify from inventory_provider.routes import common -routes = Blueprint("inventory-opsdb-query-routes", __name__) +routes = Blueprint("inventory-data-testing-support-routes", __name__) -interfaces_key = "interface_services" -equipment_locations_key = "equipment_locations" -service_child_to_parents_key = "child_to_parent_circuit_relations" -service_parent_to_children_key = "parent_to_children_circuit_relations" -interface_status_key = "interface_statuses" +@routes.route("flushdb", methods=['GET', 'POST']) +def flushdb(): + common.get_redis().flushdb() + return Response('OK') -# def _decode_utf8_dict(d): -# return {k.decode('utf8'): json.loads(v) for k, v in d.items()} -# -# -@routes.route("/interfaces") + +@routes.route("infinera-dna-addresses", methods=['GET', 'POST']) +@common.require_accepts_json +def infinera_addresses(): + infinera_config = current_app.config[ + "INVENTORY_PROVIDER_CONFIG"]["infinera-dna"] + return jsonify([dna['address'] for dna in infinera_config]) + + +@routes.route("coriant-tnms-addresses", methods=['GET', 'POST']) +@common.require_accepts_json +def coriant_addresses(): + coriant_config = current_app.config[ + "INVENTORY_PROVIDER_CONFIG"]["coriant-tnms"] + return jsonify([tnms['address'] for tnms in coriant_config]) + + +@routes.route("juniper-server-addresses", methods=['GET', 'POST']) +@common.require_accepts_json +def juniper_addresses(): + # TODO: this route (and corant, infinera routes) can be removed + r = common.get_redis() + routers = [] + for k in r.keys('junosspace:*'): + info = r.get(k.decode('utf-8')) + assert info # sanity: value shouldn't be empty + info = json.loads(info.decode('utf-8')) + routers.append(info['address']) + return jsonify(routers) + + +@routes.route("opsdb/interfaces") def get_all_interface_details(): r = common.get_redis() result = collections.defaultdict(list) @@ -31,7 +57,7 @@ def get_all_interface_details(): return jsonify(result) -@routes.route("/interfaces/<equipment_name>") +@routes.route("opsdb/interfaces/<equipment_name>") def get_interface_details_for_equipment(equipment_name): r = common.get_redis() result = [] @@ -44,7 +70,7 @@ def get_interface_details_for_equipment(equipment_name): return jsonify(result) -@routes.route("/interfaces/<equipment_name>/<path:interface>") +@routes.route("opsdb/interfaces/<equipment_name>/<path:interface>") def get_interface_details(equipment_name, interface): r = common.get_redis() key = 'opsdb:interface_services:%s:%s' % (equipment_name, interface) @@ -52,7 +78,7 @@ def get_interface_details(equipment_name, interface): return jsonify(json.loads(r.get(key).decode('utf-8'))) -@routes.route("/equipment-location") +@routes.route("opsdb/equipment-location") def get_all_equipment_locations(): r = common.get_redis() result = {} @@ -64,7 +90,7 @@ def get_all_equipment_locations(): return jsonify(result) -@routes.route("/equipment-location/<path:equipment_name>") +@routes.route("opsdb/equipment-location/<path:equipment_name>") def get_equipment_location(equipment_name): r = common.get_redis() result = r.get('opsdb:location:' + equipment_name) @@ -72,7 +98,7 @@ def get_equipment_location(equipment_name): return jsonify(json.loads(result.decode('utf-8'))) -@routes.route("/circuit-hierarchy/children/<int:parent_id>") +@routes.route("opsdb/circuit-hierarchy/children/<int:parent_id>") def get_children(parent_id): r = common.get_redis() result = r.get('opsdb:services:children:%d' % parent_id) @@ -80,7 +106,7 @@ def get_children(parent_id): return jsonify(json.loads(result.decode('utf-8'))) -@routes.route("/circuit-hierarchy/parents/<int:child_id>") +@routes.route("opsdb/circuit-hierarchy/parents/<int:child_id>") def get_parents(child_id): r = common.get_redis() result = r.get('opsdb:services:parents:%d' % child_id) diff --git a/inventory_provider/snmp.py b/inventory_provider/snmp.py index a97df15d0b5aed2899cbdda0489a736d5f40153a..a5a48b7067e75117756d779c87abdd1767718c48 100644 --- a/inventory_provider/snmp.py +++ b/inventory_provider/snmp.py @@ -6,8 +6,6 @@ from pysnmp.hlapi import nextCmd, SnmpEngine, CommunityData, \ from pysnmp.smi import builder, compiler # from pysnmp.smi import view, rfc1902 -from inventory_provider.constants import SNMP_LOGGER_NAME - def _v6address_oid2str(dotted_decimal): hex_params = [] @@ -30,7 +28,7 @@ def walk(agent_hostname, community, base_oid): # pragma: no cover :return: """ - snmp_logger = logging.getLogger(SNMP_LOGGER_NAME) + logger = logging.getLogger(__name__) mibBuilder = builder.MibBuilder() # mibViewController = view.MibViewController(mibBuilder) @@ -43,7 +41,7 @@ def walk(agent_hostname, community, base_oid): # pragma: no cover 'SNMP-COMMUNITY-MIB', 'RFC1213-MIB') - snmp_logger.debug("walking %s: %s" % (agent_hostname, base_oid)) + logger.debug("walking %s: %s" % (agent_hostname, base_oid)) for (engineErrorIndication, pduErrorIndication, @@ -57,16 +55,26 @@ def walk(agent_hostname, community, base_oid): # pragma: no cover lexicographicMode=False, lookupNames=True, lookupValues=True): - assert not engineErrorIndication - assert not pduErrorIndication - assert errorIndex == 0 + + # cf. http://snmplabs.com/ + # pysnmp/examples/hlapi/asyncore/sync/contents.html + assert not engineErrorIndication, ( + 'snmp response engine error indication: %r' + % str(engineErrorIndication)) + assert not pduErrorIndication, 'snmp response pdu error %r at %r' % ( + pduErrorIndication, + errorIndex and varBinds[int(errorIndex) - 1][0] or '?') + assert errorIndex == 0, ( + 'sanity failure: errorIndex != 0, ' + 'but no error indication') + # varBinds = [ # rfc1902.ObjectType(rfc1902.ObjectIdentity(x[0]),x[1]) # .resolveWithMib(mibViewController) # for x in varBinds] for oid, val in varBinds: result = {"oid": "." + str(oid), "value": val.prettyPrint()} - snmp_logger.debug(result) + logger.debug(result) yield result diff --git a/inventory_provider/tasks/config.py b/inventory_provider/tasks/config.py index 84d4878ed1377547e9164b3764ef21f10239dd36..9e59a19335b81715406ef31c9acec14e81158909 100644 --- a/inventory_provider/tasks/config.py +++ b/inventory_provider/tasks/config.py @@ -6,3 +6,4 @@ broker_url = getenv( result_backend = getenv( 'CELERY_BROKER_URL', default='redis://test-dashboard02.geant.org:6379/1') +task_eager_propagates = True diff --git a/inventory_provider/tasks/worker.py b/inventory_provider/tasks/worker.py index cdfc648fff7e5d261919f4fdda0a99b4420eb4ae..075660cbecc731b4e5cda344d437a9ebaa7c01e2 100644 --- a/inventory_provider/tasks/worker.py +++ b/inventory_provider/tasks/worker.py @@ -2,7 +2,8 @@ import json import logging import re -from celery import bootsteps, Task, group +from celery import bootsteps, Task, group, states +from celery.result import AsyncResult from collections import defaultdict from lxml import etree @@ -10,7 +11,6 @@ from lxml import etree from inventory_provider.tasks.app import app from inventory_provider.tasks.common import get_redis from inventory_provider import config -from inventory_provider import constants from inventory_provider import environment from inventory_provider.db import db, opsdb, alarmsdb from inventory_provider import snmp @@ -21,52 +21,52 @@ from inventory_provider import juniper environment.setup_logging() +class InventoryTaskError(Exception): + pass + + class InventoryTask(Task): config = None - # logger = None def __init__(self): pass - # @staticmethod - # def save_key(hostname, key, value): - # assert isinstance(value, str), \ - # "sanity failure: expected string data as value" - # r = get_redis(InventoryTask.config) - # r.hset( - # name=hostname, - # key=key, - # value=value) - # InventoryTask.logger.debug( - # "saved %s, key %s" % (hostname, key)) - # return "OK" - - @staticmethod - def save_value(key, value): - assert isinstance(value, str), \ - "sanity failure: expected string data as value" - r = get_redis(InventoryTask.config) - r.set(name=key, value=value) - # InventoryTask.logger.debug("saved %s" % key) - return "OK" + def update_state(self, **kwargs): + logger = logging.getLogger(__name__) + logger.debug(json.dumps( + {'state': kwargs['state'], 'meta': kwargs['meta']} + )) + super().update_state(**kwargs) + + +def _save_value(key, value): + assert isinstance(value, str), \ + "sanity failure: expected string data as value" + r = get_redis(InventoryTask.config) + r.set(name=key, value=value) + # InventoryTask.logger.debug("saved %s" % key) + return "OK" + - @staticmethod - def save_value_json(key, data_obj): - InventoryTask.save_value( - key, - json.dumps(data_obj)) +def _save_value_json(key, data_obj): + _save_value( + key, + json.dumps(data_obj)) - @staticmethod - def save_value_etree(key, xml_doc): - InventoryTask.save_value( - key, - etree.tostring(xml_doc, encoding='unicode')) + +def _save_value_etree(key, xml_doc): + _save_value( + key, + etree.tostring(xml_doc, encoding='unicode')) class WorkerArgs(bootsteps.Step): def __init__(self, worker, config_filename, **options): with open(config_filename) as f: + logger = logging.getLogger(__name__) + logger.info( + "Initializing worker with config from: %r" % config_filename) InventoryTask.config = config.load(f) @@ -85,37 +85,37 @@ app.steps['worker'].add(WorkerArgs) @app.task def snmp_refresh_interfaces(hostname, community): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug( + logger = logging.getLogger(__name__) + logger.debug( '>>> snmp_refresh_interfaces(%r, %r)' % (hostname, community)) - InventoryTask.save_value_json( + _save_value_json( 'snmp-interfaces:' + hostname, list(snmp.get_router_interfaces( hostname, community, InventoryTask.config))) - task_logger.debug( + logger.debug( '<<< snmp_refresh_interfaces(%r, %r)' % (hostname, community)) @app.task def netconf_refresh_config(hostname): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> netconf_refresh_config(%r)' % hostname) + logger = logging.getLogger(__name__) + logger.debug('>>> netconf_refresh_config(%r)' % hostname) - InventoryTask.save_value_etree( + _save_value_etree( 'netconf:' + hostname, juniper.load_config(hostname, InventoryTask.config["ssh"])) - task_logger.debug('<<< netconf_refresh_config(%r)' % hostname) + logger.debug('<<< netconf_refresh_config(%r)' % hostname) @app.task def update_interfaces_to_services(): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_interfaces_to_services') + logger = logging.getLogger(__name__) + logger.debug('>>> update_interfaces_to_services') interface_services = defaultdict(list) with db.connection(InventoryTask.config["ops-db"]) as cx: @@ -132,13 +132,13 @@ def update_interfaces_to_services(): 'opsdb:interface_services:' + equipment_interface, json.dumps(services)) - task_logger.debug('<<< update_interfaces_to_services') + logger.debug('<<< update_interfaces_to_services') @app.task def update_equipment_locations(): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_equipment_locations') + logger = logging.getLogger(__name__) + logger.debug('>>> update_equipment_locations') r = get_redis(InventoryTask.config) for key in r.scan_iter('opsdb:location:*'): @@ -147,13 +147,13 @@ def update_equipment_locations(): for ld in opsdb.get_equipment_location_data(cx): r.set('opsdb:location:%s' % ld['equipment_name'], json.dumps(ld)) - task_logger.debug('<<< update_equipment_locations') + logger.debug('<<< update_equipment_locations') @app.task def update_circuit_hierarchy(): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_circuit_hierarchy') + logger = logging.getLogger(__name__) + logger.debug('>>> update_circuit_hierarchy') # TODO: integers are not JSON keys with db.connection(InventoryTask.config["ops-db"]) as cx: @@ -176,13 +176,13 @@ def update_circuit_hierarchy(): for cid, children in child_to_parents.items(): r.set('opsdb:services:children:%d' % cid, json.dumps(children)) - task_logger.debug('<<< update_circuit_hierarchy') + logger.debug('<<< update_circuit_hierarchy') @app.task def update_interface_statuses(): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_interface_statuses') + logger = logging.getLogger(__name__) + logger.debug('>>> update_interface_statuses') with db.connection(InventoryTask.config["ops-db"]) as cx: services = opsdb.get_circuits(cx) @@ -195,24 +195,48 @@ def update_interface_statuses(): csr, service["equipment"], service["interface_name"]) - InventoryTask.save_value(key, status) + _save_value(key, status) - task_logger.debug('<<< update_interface_statuses') + logger.debug('<<< update_interface_statuses') -@app.task -def update_junosspace_device_list(): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_junosspace_device_list') +@app.task(base=InventoryTask, bind=True) +def update_junosspace_device_list(self): + logger = logging.getLogger(__name__) + logger.debug('>>> update_junosspace_device_list') + + self.update_state( + state=states.STARTED, + meta={ + 'task': 'update_junosspace_device_list', + 'message': 'querying junosspace for managed routers' + }) r = get_redis(InventoryTask.config) + + routers = {} for d in juniper.load_routers_from_junosspace( - InventoryTask.config["junosspace"]): - r.set( - 'junosspace:' + d['hostname'], - json.dumps(d).encode('utf-8')) + InventoryTask.config['junosspace']): + routers['junosspace:' + d['hostname']] = json.dumps(d).encode('utf-8') + + self.update_state( + state=states.STARTED, + meta={ + 'task': 'update_junosspace_device_list', + 'message': 'found %d routers, saving details' % len(routers) + }) - task_logger.debug('<<< update_junosspace_device_list') + for k in r.keys('junosspace:*'): + r.delete(k) + for k, v in routers.items(): + r.set(k, v) + + logger.debug('<<< update_junosspace_device_list') + + return { + 'task': 'update_junosspace_device_list', + 'message': 'saved %d managed routers' % len(routers) + } def load_netconf_data(hostname): @@ -225,13 +249,13 @@ def load_netconf_data(hostname): r = get_redis(InventoryTask.config) netconf = r.get('netconf:' + hostname) if not netconf: - return None + raise InventoryTaskError('no netconf data found for %r' % hostname) return etree.fromstring(netconf.decode('utf-8')) def clear_cached_classifier_responses(hostname): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug( + logger = logging.getLogger(__name__) + logger.debug( 'removing cached classifier responses for %r' % hostname) r = get_redis(InventoryTask.config) for k in r.keys('classifier:cache:%s:*' % hostname): @@ -239,8 +263,8 @@ def clear_cached_classifier_responses(hostname): def _refresh_peers(hostname, key_base, peers): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug( + logger = logging.getLogger(__name__) + logger.debug( 'removing cached %s for %r' % (key_base, hostname)) r = get_redis(InventoryTask.config) for k in r.keys(key_base + ':*'): @@ -274,32 +298,91 @@ def refresh_vpn_rr_peers(hostname, netconf): juniper.vpn_rr_peers(netconf)) -@app.task -def reload_router_config(hostname): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_router_config') - +def refresh_interface_address_lookups(hostname, netconf): + _refresh_peers( + hostname, + 'reverse_interface_addresses', + juniper.interface_addresses(netconf)) + + +@app.task(base=InventoryTask, bind=True) +def reload_router_config(self, hostname): + logger = logging.getLogger(__name__) + logger.debug('>>> reload_router_config') + + self.update_state( + state=states.STARTED, + meta={ + 'task': 'reload_router_config', + 'hostname': hostname, + 'message': 'loading router netconf data' + }) + + # get the timestamp for the current netconf data + current_netconf_timestamp = None + try: + netconf_doc = load_netconf_data(hostname) + current_netconf_timestamp \ + = juniper.netconf_changed_timestamp(netconf_doc) + logger.debug( + 'current netconf timestamp: %r' % current_netconf_timestamp) + except InventoryTaskError: + pass # ok at this point if not found + + # load new netconf data netconf_refresh_config.apply(args=[hostname]) netconf_doc = load_netconf_data(hostname) - if netconf_doc is None: - task_logger.error('no netconf data available for %r' % hostname) - else: - - refresh_ix_public_peers(hostname, netconf_doc) - refresh_vpn_rr_peers(hostname, netconf_doc) - community = juniper.snmp_community_string(netconf_doc) - if not community: - task_logger.error( - 'error extracting community string for %r' % hostname) - else: - snmp_refresh_interfaces.apply(args=[hostname, community]) + # return if new timestamp is the same as the original timestamp + new_netconf_timestamp = juniper.netconf_changed_timestamp(netconf_doc) + assert new_netconf_timestamp, \ + 'no timestamp available for new netconf data' + if new_netconf_timestamp == current_netconf_timestamp: + logger.debug('no netconf change timestamp change, aborting') + logger.debug('<<< reload_router_config') + return { + 'task': 'reload_router_config', + 'hostname': hostname, + 'message': 'OK (no change)' + } + + # clear cached classifier responses for this router, and + # refresh peering data + self.update_state( + state=states.STARTED, + meta={ + 'task': 'reload_router_config', + 'hostname': hostname, + 'message': 'refreshing peers & clearing cache' + }) + refresh_ix_public_peers(hostname, netconf_doc) + refresh_vpn_rr_peers(hostname, netconf_doc) + refresh_interface_address_lookups(hostname, netconf_doc) + clear_cached_classifier_responses(hostname) + + # load snmp indexes + community = juniper.snmp_community_string(netconf_doc) + if not community: + raise InventoryTaskError( + 'error extracting community string for %r' % hostname) + else: + self.update_state( + state=states.STARTED, + meta={ + 'task': 'reload_router_config', + 'hostname': hostname, + 'message': 'refreshing snmp interface indexes' + }) + snmp_refresh_interfaces.apply(args=[hostname, community]) - # TODO: move this out of else? (i.e. clear even if netconf fails?) - clear_cached_classifier_responses(hostname) + logger.debug('<<< reload_router_config') - task_logger.debug('<<< update_router_config') + return { + 'task': 'reload_router_config', + 'hostname': hostname, + 'message': 'OK' + } def _derive_router_hostnames(config): @@ -320,13 +403,13 @@ def _derive_router_hostnames(config): return junosspace_equipment & opsdb_equipment -def start_refresh_cache_all(config): +def launch_refresh_cache_all(config): """ utility function intended to be called outside of the worker process :param config: config structure as defined in config.py :return: """ - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) + logger = logging.getLogger(__name__) # first batch of subtasks: refresh cached opsdb data subtasks = [ @@ -346,8 +429,30 @@ def start_refresh_cache_all(config): update_interface_statuses.s() ] for hostname in _derive_router_hostnames(config): - task_logger.debug( + logger.debug( 'queueing router refresh jobs for %r' % hostname) subtasks.append(reload_router_config.s(hostname)) - return group(subtasks).apply_async() + return [r.id for r in group(subtasks).apply_async()] + + +def check_task_status(task_id): + r = AsyncResult(task_id, app=app) + result = { + 'id': r.id, + 'status': r.status, + 'exception': r.status in states.EXCEPTION_STATES, + 'ready': r.status in states.READY_STATES, + 'success': r.status == states.SUCCESS, + } + if r.result: + # TODO: only discovered this case by testing, is this the only one? + # ... otherwise need to pre-test json serialization + if isinstance(r.result, Exception): + result['result'] = { + 'error type': type(r.result).__name__, + 'message': str(r.result) + } + else: + result['result'] = r.result + return result diff --git a/test/conftest.py b/test/conftest.py index ca7000226686d4739bf587978e1500d831406c6b..0a9a3cec059287e5068958351e8d21a81a578808 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -115,6 +115,10 @@ class MockedRedis(object): k.encode("utf-8") for k in MockedRedis.db.keys() if k.startswith(m.group(1))]) + def flushdb(self): + # only called from testing routes (hopefully) + pass + @pytest.fixture def data_config(): @@ -137,6 +141,7 @@ def app_config(): f.write("%s = '%s'\n" % ( "INVENTORY_PROVIDER_CONFIG_FILENAME", data_config_filename(tmpdir))) + f.write('ENABLE_TESTING_ROUTES = True\n') yield app_config_filename diff --git a/test/per_router/test_celery_worker.py b/test/per_router/test_celery_worker.py index 81ad1be8b87c8637d70694c4464239229e9d855d..fa8037043e5e8b3ba3e85ae2489c0f0f14bcb0d9 100644 --- a/test/per_router/test_celery_worker.py +++ b/test/per_router/test_celery_worker.py @@ -133,9 +133,11 @@ def test_reload_router_config(mocked_worker_module, router, mocker): 'inventory_provider.tasks.worker.snmp_refresh_interfaces.apply', _mocked_snmp_refresh_interfaces_apply) + def _mocked_update_status(self, **kwargs): + pass mocker.patch( - 'inventory_provider.tasks.worker.snmp_refresh_interfaces.apply', - _mocked_snmp_refresh_interfaces_apply) + 'inventory_provider.tasks.worker.InventoryTask.update_state', + _mocked_update_status) worker.reload_router_config(router) assert 'netconf:' + router in MockedRedis.db diff --git a/test/per_router/test_juniper_data.py b/test/per_router/test_juniper_data.py index 2574209e2f74c9ea19ee67dab924c7415d415ce1..721477640e199595517261c8107d954c39160de5 100644 --- a/test/per_router/test_juniper_data.py +++ b/test/per_router/test_juniper_data.py @@ -102,3 +102,53 @@ def test_bgp_list(netconf_doc): def test_snmp_community_string(mocked_netifaces, netconf_doc): assert juniper.snmp_community_string(netconf_doc) == '0pBiFbD' + + +def test_interface_addresses_list(netconf_doc): + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "v4a": { + "type": "string", + "pattern": r'^(\d+\.){3}\d+$' + }, + "v6a": { + "type": "string", + "pattern": r'^([a-f\d]{4}:){7}[a-f\d]{4}$' + }, + "v4i": { + "type": "string", + "pattern": r'^(\d+\.){3}\d+/\d+$' + }, + "v6i": { + "type": "string", + "pattern": r'^[a-f\d:]+/\d+$' + } + }, + + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "oneOf": [ + {"$ref": "#/definitions/v4a"}, + {"$ref": "#/definitions/v6a"} + ] + }, + "interface address": { + "oneOf": [ + {"$ref": "#/definitions/v4i"}, + {"$ref": "#/definitions/v6i"} + ] + }, + "interface name": {"type": "string"}, + }, + "required": ["name", "interface address", "interface name"], + "additionalProperties": False + } + } + + addresses = list(juniper.interface_addresses(netconf_doc)) + jsonschema.validate(addresses, schema) diff --git a/test/test_classifier_routes.py b/test/test_classifier_routes.py index 7ea6f6c810f7ac638334f239423f6972123caba5..6b68221d42e1805c373990ea12cbdc8950bb57db 100644 --- a/test/test_classifier_routes.py +++ b/test/test_classifier_routes.py @@ -7,55 +7,6 @@ DEFAULT_REQUEST_HEADERS = { } -def test_infinera_addresses(client): - response_schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "array", - "items": {"type": "string"} - } - - rv = client.post( - "/classifier/infinera-dna-addresses", - headers=DEFAULT_REQUEST_HEADERS) - assert rv.status_code == 200 - jsonschema.validate( - json.loads(rv.data.decode("utf-8")), - response_schema) - - -def test_coriant_addresses(client): - response_schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "array", - "items": {"type": "string"} - } - - rv = client.post( - "/classifier/coriant-tnms-addresses", - headers=DEFAULT_REQUEST_HEADERS) - assert rv.status_code == 200 - jsonschema.validate( - json.loads(rv.data.decode("utf-8")), - response_schema) - - -def test_juniper_addresses(mocker, client): - - response_schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "array", - "items": {"type": "string"} - } - - rv = client.post( - "/classifier/juniper-server-addresses", - headers=DEFAULT_REQUEST_HEADERS) - assert rv.status_code == 200 - response_data = json.loads(rv.data.decode('utf-8')) - jsonschema.validate(response_data, response_schema) - assert len(response_data) > 0 # test data is not empty - - def test_trap_metadata(client_with_mocked_data): response_schema = { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/test/test_job_routes.py b/test/test_job_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..427ceafd39d521d88eb848b450add5258745ba21 --- /dev/null +++ b/test/test_job_routes.py @@ -0,0 +1,125 @@ +import json +import jsonschema + +DEFAULT_REQUEST_HEADERS = { + "Content-type": "application/json", + "Accept": ["application/json"] +} + +TASK_ID_LIST_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": {"type": "string"} +} + +TASK_STATUS_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": {"type": "string"}, + "status": {"type": "string"}, + "exception": {"type": "boolean"}, + "ready": {"type": "boolean"}, + "success": {"type": "boolean"}, + "result": {"type": "object"} + }, + "required": ["id", "status", "exception", "ready", "success"], + "additionalProperties": False +} + + +def test_job_update_all(client, mocker): + launch_refresh_cache_all = mocker.patch( + 'inventory_provider.tasks.worker.launch_refresh_cache_all') + launch_refresh_cache_all.return_value = ['abc', 'def', 'xyz@123#456'] + + rv = client.post( + 'jobs/update', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + task_id_list = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(task_id_list, TASK_ID_LIST_SCHEMA) + assert len(task_id_list) == 3 + + +class MockedAsyncResult(object): + status = None + result = None + + def __init__(self, id, app=None): + self.id = id + + +def test_reload_router_config(client, mocker): + delay_result = mocker.patch( + 'inventory_provider.tasks.worker.reload_router_config.delay') + delay_result.return_value = MockedAsyncResult('bogus task id') + + rv = client.post( + 'jobs/reload-router-config/ignored###123', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + task_id_list = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(task_id_list, TASK_ID_LIST_SCHEMA) + assert task_id_list == ['bogus task id'] + + +def test_check_task_status_success(client, mocker): + mocker.patch( + 'inventory_provider.tasks.worker.AsyncResult', MockedAsyncResult) + MockedAsyncResult.status = 'SUCCESS' # celery.states.SUCCESS + MockedAsyncResult.result = {'abc': 1, 'def': 'aaabbb'} + + rv = client.post( + 'jobs/check-task-status/abc', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + status = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(status, TASK_STATUS_SCHEMA) + assert status['id'] == 'abc' + assert status['status'] == 'SUCCESS' + assert not status['exception'] + assert status['ready'] + assert status['success'] + assert 'result' in status + + +def test_check_task_status_custom_status(client, mocker): + mocker.patch( + 'inventory_provider.tasks.worker.AsyncResult', MockedAsyncResult) + MockedAsyncResult.status = 'custom' + MockedAsyncResult.result = None + + rv = client.post( + 'jobs/check-task-status/xyz', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + status = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(status, TASK_STATUS_SCHEMA) + assert status['id'] == 'xyz' + assert status['status'] == 'custom' + assert not status['exception'] + assert not status['ready'] + assert not status['success'] + assert 'result' not in status + + +def test_check_task_status_exception(client, mocker): + mocker.patch( + 'inventory_provider.tasks.worker.AsyncResult', MockedAsyncResult) + MockedAsyncResult.status = 'FAILURE' # celery.states.FAILURE + MockedAsyncResult.result = AssertionError('test error message') + + rv = client.post( + 'jobs/check-task-status/123-xyz.ABC', + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + status = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(status, TASK_STATUS_SCHEMA) + assert status['id'] == '123-xyz.ABC' + assert status['status'] == 'FAILURE' + assert status['exception'] + assert status['ready'] + assert not status['success'] + assert status['result']['error type'] == 'AssertionError' + assert status['result']['message'] == 'test error message' diff --git a/test/test_snmp_handling.py b/test/test_snmp_handling.py index f8138a68e6a090befa102cf3ab50eed7b656f325..08485456e629bc31821555240bf4c6d97137493f 100644 --- a/test/test_snmp_handling.py +++ b/test/test_snmp_handling.py @@ -26,20 +26,58 @@ def test_snmp_interfaces(mocker, data_config, snmp_walk_responses): expected_result_schema = { "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "v4ifc": { + "type": "object", + "properties": { + "v4Address": { + "type": "string", + "pattern": r'^(\d+\.){3}\d+$' + }, + "v4Mask": { + "type": "string", + "pattern": r'^(\d+\.){3}\d+$' + }, + "v4InterfaceName": {"type": "string"}, + "index": { + "type": "string", + "pattern": r'^\d+$' + } + }, + "required": [ + "v4Address", "v4Mask", "v4InterfaceName", "index"], + "additionalProperties": False + }, + "v6ifc": { + "type": "object", + "properties": { + "v6Address": { + "type": "string", + "pattern": r'^[a-f\d:]+$' + }, + "v6Mask": { + "type": "string", + "pattern": r'^\d+$' + }, + "v6InterfaceName": {"type": "string"}, + "index": { + "type": "string", + "pattern": r'^\d+$' + } + }, + "required": [ + "v6Address", "v6Mask", "v6InterfaceName", "index"], + "additionalProperties": False + } + }, + "type": "array", "items": { - "type": "object", - "properties": { - "v4Address": {"type": "string"}, - "v4Mask": {"type": "string"}, - "v4InterfaceName": {"type": "string"}, - "v6Address": {"type": "string"}, - "v6Mask": {"type": "string"}, - "v6InterfaceName": {"type": "string"}, - "index": {"type": "string"} - }, - "required": ["index"], - "additionalProperties": False + "anyOf": [ + {"$ref": "#/definitions/v4ifc"}, + {"$ref": "#/definitions/v6ifc"} + ] } } @@ -57,13 +95,3 @@ def test_snmp_interfaces(mocker, data_config, snmp_walk_responses): jsonschema.validate(interfaces, expected_result_schema) assert interfaces, "interface list isn't empty" - for ifc in interfaces: - if 'v4Address' in ifc \ - and 'v4Mask' in ifc \ - and 'v4InterfaceName' in ifc: - continue - if 'v6Address' in ifc \ - and 'v6Mask' in ifc \ - and 'v6InterfaceName' in ifc: - continue - assert False, "address details not found in interface dict" diff --git a/test/test_external_inventory_routes.py b/test/test_testing_routes.py similarity index 50% rename from test/test_external_inventory_routes.py rename to test/test_testing_routes.py index 94687251b8fc35075d8a8763a47d9cd69bc4dc87..2ab4bd1930514a772f5d7b5f97b265dbe4c3f7ee 100644 --- a/test/test_external_inventory_routes.py +++ b/test/test_testing_routes.py @@ -1,13 +1,56 @@ +import json +import jsonschema DEFAULT_REQUEST_HEADERS = { "Content-type": "application/json", "Accept": ["application/json"] } +ROUTER_LIST_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": {"type": "string"} +} + + +def test_flushdb(client): + rv = client.post("/testing/flushdb") + assert rv.status_code == 200 + + +def test_infinera_addresses(client): + rv = client.post( + "/testing/infinera-dna-addresses", + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + jsonschema.validate( + json.loads(rv.data.decode("utf-8")), + ROUTER_LIST_SCHEMA) + + +def test_coriant_addresses(client): + rv = client.post( + "/testing/coriant-tnms-addresses", + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + jsonschema.validate( + json.loads(rv.data.decode("utf-8")), + ROUTER_LIST_SCHEMA) + + +def test_juniper_addresses(client): + rv = client.post( + "/testing/juniper-server-addresses", + headers=DEFAULT_REQUEST_HEADERS) + assert rv.status_code == 200 + response_data = json.loads(rv.data.decode('utf-8')) + jsonschema.validate(response_data, ROUTER_LIST_SCHEMA) + assert len(response_data) > 0 # test data is not empty + def test_get_equipment_location(client_with_mocked_data): rv = client_with_mocked_data.get( - '/opsdb/equipment-location', + '/testing/opsdb/equipment-location', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json @@ -16,7 +59,7 @@ def test_get_equipment_location(client_with_mocked_data): def test_get_interface_info(client_with_mocked_data): rv = client_with_mocked_data.get( - '/opsdb/interfaces', + '/testing/opsdb/interfaces', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json @@ -25,7 +68,7 @@ def test_get_interface_info(client_with_mocked_data): def test_get_interface_info_for_equipment(client_with_mocked_data): rv = client_with_mocked_data.get( - '/opsdb/interfaces/mx1.ams.nl.geant.net', + '/testing/opsdb/interfaces/mx1.ams.nl.geant.net', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json @@ -35,7 +78,7 @@ def test_get_interface_info_for_equipment(client_with_mocked_data): def test_get_interface_info_for_equipment_and_interface( client_with_mocked_data): rv = client_with_mocked_data.get( - '/opsdb/interfaces/mx1.ams.nl.geant.net/ae0.0', + '/testing/opsdb/interfaces/mx1.ams.nl.geant.net/ae0.0', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json @@ -44,7 +87,7 @@ def test_get_interface_info_for_equipment_and_interface( def test_get_children(client_with_mocked_data): rv = client_with_mocked_data.get( - '/opsdb/circuit-hierarchy/children/12363', + '/testing/opsdb/circuit-hierarchy/children/12363', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json @@ -53,7 +96,7 @@ def test_get_children(client_with_mocked_data): def test_get_parents(client_with_mocked_data): rv = client_with_mocked_data.get( - '/opsdb/circuit-hierarchy/parents/11725', + '/testing/opsdb/circuit-hierarchy/parents/11725', headers=DEFAULT_REQUEST_HEADERS) assert rv.status_code == 200 assert rv.is_json