diff --git a/README.md b/README.md index 93ef44eea42beb5978bfc3bef875c0abf6f8551e..be345ee3961cf134eb1106d2e8c42663f46e70dc 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,32 +303,51 @@ 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: -* /jobs/update-startup + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": {"type": "string"} + } + ``` - This resource updates data that should only be refreshed - in case of system restart. - -* /jobs/update-interfaces-to-services +* /jobs/reload-router-config/<equipment-name> - This resource updates the information that lists all the services for - interfaces found in the external inventory system - -* /jobs/update-service-hierarchy + 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 all the information showing which services are related - to one-another - -* /jobs/update-equipment-locations +* /jobs/check-task-status/<task-id>" + + 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 @@ -356,15 +425,25 @@ Any non-empty responses are JSON formatted messages. } ``` -## backend (Redis) storage schema -`netconf:<hostname>` +* `/testing/flushdb` + + This method erases all data in the backend redis + data bases and is only available if the server was + started with the `ENABLE_TESTING_ROUTES` flag. + + +## 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 +458,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 +479,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 +583,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 +614,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 +667,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 +678,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 +716,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: diff --git a/inventory_provider/constants.py b/inventory_provider/constants.py deleted file mode 100644 index 3925c65bb0a7e8410de907d77899e5c92cf6b882..0000000000000000000000000000000000000000 --- a/inventory_provider/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -SNMP_LOGGER_NAME = "inventory_provider.snmp" -JUNIPER_LOGGER_NAME = "inventory_provider.juniper" -DATABASE_LOGGER_NAME = "inventory_provider.database" -TASK_LOGGER_NAME = "inventory_provider.task" diff --git a/inventory_provider/juniper.py b/inventory_provider/juniper.py index 741ef352381f1b6213e9bfc936fcbe8ac0c3ee61..3f33c8d94470f088f627562aa513ff491137f293 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 @@ -313,7 +315,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 +330,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 +338,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 +348,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], diff --git a/inventory_provider/logging_default_config.json b/inventory_provider/logging_default_config.json index 2ab40c772446f65e022057395ab9a07cd0e91d51..ba3f1eb38510e597063d1d27ceddbea4c0286f10 100644 --- a/inventory_provider/logging_default_config.json +++ b/inventory_provider/logging_default_config.json @@ -46,27 +46,13 @@ "loggers": { "inventory_provider": { - "level": "DEBUG", + "level": "INFO", "handlers": ["console", "syslog_handler"], "propagate": false }, - "inventory_provider.snmp": { - "level": "INFO" - }, - "inventory_provider.juniper": { - "level": "INFO" - }, - "inventory_provider.database": { - "level": "INFO" - }, - "inventory_provider.task": { + "inventory_provider.tasks": { "level": "DEBUG" - }, - "celery.app.trace": { - "level": "INFO", - "handlers": ["console", "syslog_handler"], - "propagate": false - } + } }, "root": { diff --git a/inventory_provider/routes/jobs.py b/inventory_provider/routes/jobs.py index 99be6cff92fa5b59389b144c02823b4aed4d0038..02e778ab4b0bd8d4b85fc44ae79dd33c663701f8 100644 --- a/inventory_provider/routes/jobs.py +++ b/inventory_provider/routes/jobs.py @@ -1,28 +1,32 @@ 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(): job_ids = worker.launch_refresh_cache_all( current_app.config["INVENTORY_PROVIDER_CONFIG"]) 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): result = worker.reload_router_config.delay(equipment_name) return jsonify([result.id]) -@routes.route("check-task-status/<task_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/snmp.py b/inventory_provider/snmp.py index 0c2c50461f886e5c719101de3622c3b1dad71c3b..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, @@ -76,7 +74,7 @@ def walk(agent_hostname, community, base_oid): # pragma: no cover # 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/worker.py b/inventory_provider/tasks/worker.py index 3b7db454efc165a0ec5a5b20e2e8cb70f47ab5e2..87020db6963d57a858724b26865f3419b398fe21 100644 --- a/inventory_provider/tasks/worker.py +++ b/inventory_provider/tasks/worker.py @@ -11,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 @@ -34,8 +33,8 @@ class InventoryTask(Task): pass def update_state(self, **kwargs): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug(json.dumps( + logger = logging.getLogger(__name__) + logger.debug(json.dumps( {'state': kwargs['state'], 'meta': kwargs['meta']} )) super().update_state(**kwargs) @@ -65,8 +64,8 @@ def _save_value_etree(key, xml_doc): class WorkerArgs(bootsteps.Step): def __init__(self, worker, config_filename, **options): with open(config_filename) as f: - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.info( + logger = logging.getLogger(__name__) + logger.info( "Initializing worker with config from: %r" % config_filename) InventoryTask.config = config.load(f) @@ -86,8 +85,8 @@ 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)) _save_value_json( @@ -97,26 +96,26 @@ def snmp_refresh_interfaces(hostname, community): 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) _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: @@ -133,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:*'): @@ -148,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: @@ -177,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) @@ -198,13 +197,13 @@ def update_interface_statuses(): service["interface_name"]) _save_value(key, status) - task_logger.debug('<<< update_interface_statuses') + logger.debug('<<< update_interface_statuses') @app.task(base=InventoryTask, bind=True) def update_junosspace_device_list(self): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> update_junosspace_device_list') + logger = logging.getLogger(__name__) + logger.debug('>>> update_junosspace_device_list') self.update_state( state=states.STARTED, @@ -232,7 +231,7 @@ def update_junosspace_device_list(self): for k, v in routers.items(): r.set(k, v) - task_logger.debug('<<< update_junosspace_device_list') + logger.debug('<<< update_junosspace_device_list') return { 'task': 'update_junosspace_device_list', @@ -255,8 +254,8 @@ def load_netconf_data(hostname): 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): @@ -264,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 + ':*'): @@ -301,8 +300,8 @@ def refresh_vpn_rr_peers(hostname, netconf): @app.task(base=InventoryTask, bind=True) def reload_router_config(self, hostname): - task_logger = logging.getLogger(constants.TASK_LOGGER_NAME) - task_logger.debug('>>> reload_router_config') + logger = logging.getLogger(__name__) + logger.debug('>>> reload_router_config') self.update_state( state=states.STARTED, @@ -346,7 +345,7 @@ def reload_router_config(self, hostname): # TODO: move this out of else? (i.e. clear even if netconf fails?) clear_cached_classifier_responses(hostname) - task_logger.debug('<<< reload_router_config') + logger.debug('<<< reload_router_config') return { 'task': 'reload_router_config', @@ -379,7 +378,7 @@ def launch_refresh_cache_all(config): :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 = [ @@ -399,7 +398,7 @@ def launch_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)) @@ -409,7 +408,7 @@ def launch_refresh_cache_all(config): def check_task_status(task_id): r = AsyncResult(task_id, app=app) result = { - 'id': task_id, + 'id': r.id, 'status': r.status, 'exception': r.status in states.EXCEPTION_STATES, 'ready': r.status in states.READY_STATES, 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"