diff --git a/brian_polling_manager/__init__.py b/brian_polling_manager/__init__.py index 50c8ab59ae75d2781aea5852c1fd6dd8fe1667f9..d3dbc19f3741c5d05983ac8f9878a570c22d5247 100644 --- a/brian_polling_manager/__init__.py +++ b/brian_polling_manager/__init__.py @@ -1,37 +1,37 @@ -# """ -# automatically invoked app factory -# """ -# import logging -# import os -# -# from flask import Flask -# -# import dashboard_v3_webapp.config as config -# -# CONFIG_KEY = 'CONFIG_PARAMS' -# SESSION_SECRET = 'super-secret' -# -# -# def create_app(): -# """ -# overrides default settings with those found -# in the file read from env var CONFIG_FILENAME -# -# :return: a new flask app instance -# """ -# -# app_config = config.DEFAULT_PARAMS -# if 'CONFIG_FILENAME' in os.environ: -# with open(os.environ['CONFIG_FILENAME']) as f: -# app_config.update(config.load(f)) -# -# app = Flask(__name__) -# app.secret_key = SESSION_SECRET -# app.config[CONFIG_KEY] = app_config -# -# from dashboard_v3_webapp import api -# app.register_blueprint(api.routes, url_prefix='/api') -# -# logging.info('Flask app initialized') -# # environment.setup_logging() -# return app +""" +automatically invoked app factory +""" +import logging +import os + +from flask import Flask + +from brian_polling_manager import configuration + +CONFIG_KEY = 'CONFIG_PARAMS' +SESSION_SECRET = 'super-secret' + + +def create_app(): + """ + overrides default settings with those found + in the file read from env var CONFIG_FILENAME + + :return: a new flask app instance + """ + + app = Flask(__name__) + app.secret_key = SESSION_SECRET + + if 'CONFIG_FILENAME' in os.environ: + with open(os.environ['CONFIG_FILENAME']) as f: + app.config[CONFIG_KEY] = configuration.load_config(f) + else: + # this inits with the defaults + app.config[CONFIG_KEY] = configuration.load_config() + + from brian_polling_manager import api + app.register_blueprint(api.routes, url_prefix='/api') + + logging.info('Flask app initialized') + return app diff --git a/brian_polling_manager/api.py b/brian_polling_manager/api.py new file mode 100644 index 0000000000000000000000000000000000000000..9e7fbdade88ddf6e87127330aabace52bd55f8d2 --- /dev/null +++ b/brian_polling_manager/api.py @@ -0,0 +1,163 @@ +""" +These endpoints are used to update BRIAN snmp polling checks to Sensu + +.. contents:: :local: + + +/api/version +--------------------- + +.. autofunction:: brian_polling_manager.api.version + + +/api/update +----------------------------- + +.. autofunction:: brian_polling_manager.api.update + +""" +import functools +import logging +import pkg_resources + +from flask import Blueprint, current_app, request, Response, jsonify + +from brian_polling_manager import CONFIG_KEY +from brian_polling_manager.main import refresh + +routes = Blueprint("api-routes", __name__) +logger = logging.getLogger(__name__) + + +API_VERSION = '0.1' + +VERSION_SCHEMA = { + '$schema': 'http://json-schema.org/draft-07/schema#', + + 'definitions': { + 'latch': { + 'type': 'object', + 'properties': { + 'current': {'type': 'integer'}, + 'next': {'type': 'integer'}, + 'this': {'type': 'integer'}, + 'failure': {'type': 'boolean'}, + 'pending': {'type': 'boolean'}, + 'timestamp': {'type': 'number'} + }, + 'required': ['current', 'next', 'this', 'pending', 'failure'], + 'additionalProperties': False + } + }, + + 'type': 'object', + 'properties': { + 'api': { + 'type': 'string', + 'pattern': r'\d+\.\d+' + }, + 'module': { + 'type': 'string', + 'pattern': r'\d+\.\d+' + }, + 'latch': {'$ref': '#/definitions/latch'} + }, + 'required': ['api', 'module'], + 'additionalProperties': False +} + + +class APIUpstreamError(Exception): + def __init__(self, message, status_code=504): + super().__init__() + self.message = message + self.status_code = status_code + + +@routes.errorhandler(APIUpstreamError) +def handle_request_error(error): + logger.error(f'api error: {error.message}') + return Response( + response=error.message, + status=error.status_code, + mimetype='text/html') + + +def require_accepts_json(f): + """ + used as a route handler decorator to return an error + unless the request allows responses with type "application/json" + :param f: the function to be decorated + :return: the decorated function + """ + @functools.wraps(f) + def decorated_function(*args, **kwargs): + # TODO: use best_match to disallow */* ...? + if not request.accept_mimetypes.accept_json: + return Response( + response="response will be json", + status=406, + mimetype="text/html") + return f(*args, **kwargs) + return decorated_function + + +@routes.after_request +def after_request(response): + """ + Generic function to do additional logging of requests & responses. + + :param response: + :return: + """ + if response.status_code != 200: + + try: + data = response.data.decode('utf-8') + except Exception: + # never expected to happen, but we don't want any failures here + logging.exception('INTERNAL DECODING ERROR') + data = 'decoding error (see logs)' + + logger.warning( + f'[{response.status_code}] {request.method} {request.path} {data}') + else: + logger.info( + f'[{response.status_code}] {request.method} {request.path}') + return response + + +@routes.route('/version', methods=['GET', 'POST']) +@require_accepts_json +def version(): + """ + Returns a json object with information about the module version. + + The response will be formatted according to the following schema: + + .. asjson:: brian_polling_manager.api.VERSION_SCHEMA + + :return: + """ + version_params = { + 'api': API_VERSION, + 'module': + pkg_resources.get_distribution('brian_polling_manager').version + } + return jsonify(version_params) + + +@routes.route('/update', methods=['GET', 'POST']) +@require_accepts_json +def update(): + """ + Update checks based on current inventory provider state. + + The response will be formatted according to the following schema: + + .. asjson:: brian_polling_manager.main.REFRESH_RESULT_SCHEMA + + :return: + """ + response = refresh(config=current_app.config[CONFIG_KEY]) + return jsonify(response) diff --git a/brian_polling_manager/configuration.py b/brian_polling_manager/configuration.py index c09ab5d03896579aab2b273bd5cb65b36858a90c..bbb136dd4601b54c3863813d80a39eb4b2463f6d 100644 --- a/brian_polling_manager/configuration.py +++ b/brian_polling_manager/configuration.py @@ -204,7 +204,7 @@ def _setup_logging(filename=None): # logging.config.dictConfig(json.loads(f.read())) -def load_config(file): +def load_config(file=None): """ loads, validates and returns configuration parameters @@ -213,7 +213,7 @@ def load_config(file): :raises: json.JSONDecodeError, jsonschema.ValidationError, OSError, AttributeError, ValueError, TypeError, ImportError """ - if file is None: + if not file: config = _DEFAULT_CONFIG else: config = json.loads(file.read()) diff --git a/brian_polling_manager/interfaces.py b/brian_polling_manager/interfaces.py index 30de108cd3c1a4f47efcda946b88e9c6a9be9602..2c0adef6f595f21ee42d64d32c1a89394cf40727 100644 --- a/brian_polling_manager/interfaces.py +++ b/brian_polling_manager/interfaces.py @@ -97,6 +97,7 @@ def refresh(sensu_params, state): for name in extra_checks: sensu.delete_check(sensu_params, name) + # cf. main.REFRESH_RESULT_SCHEMA return { 'checks': len(ifc_checks), 'input': interfaces, diff --git a/brian_polling_manager/main.py b/brian_polling_manager/main.py index 03969f48f04caaa424ac6fd4a9ded7bff9f9974d..947a37063f4649101ac8a6ac5001314b93172753 100644 --- a/brian_polling_manager/main.py +++ b/brian_polling_manager/main.py @@ -33,14 +33,43 @@ from brian_polling_manager import inventory, interfaces, configuration logger = logging.getLogger(__name__) +REFRESH_RESULT_SCHEMA = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'definitions': { + 'refresh-result': { + 'type': 'object', + 'properties': { + 'checks': {'type': 'integer'}, + 'input': {'type': 'integer'}, + 'created': {'type': 'integer'}, + 'updated': {'type': 'integer'}, + 'deleted': {'type': 'integer'} + }, + 'required': ['checks', 'input', 'created', 'updated', 'deleted'], + 'additionalProperties': False + } + }, + 'type': 'object', + 'properties': { + 'interfaces': {'$ref': '#/definitions/refresh-result'} + }, + 'required': ['interfaces'], + 'additionalProperties': False +} + def refresh(config, force=False): """ reload inventory data & update sensu checks + The output will be a dict formatted according to the following schema: + + .. asjson:: + brian_polling_manager.main.REFRESH_RESULT_SCHEMA + :param config: a dict returned by configuration.load_config :param force: if True, reload inventory data even if timestamp is same - :return: + :return: a dict, formatted as above """ state = configuration.State(config['statedir']) last = inventory.last_update_timestamp(config['inventory']) @@ -48,7 +77,9 @@ def refresh(config, force=False): state.last = last state.interfaces = inventory.load_interfaces(config['inventory']) - result = interfaces.refresh(config['sensu'], state) + result = { + 'interfaces': interfaces.refresh(config['sensu'], state) + } statsd_config = config.get('statsd', None) if statsd_config: @@ -56,13 +87,14 @@ def refresh(config, force=False): host=statsd_config['hostname'], port=statsd_config['port'], prefix=f'{statsd_config["prefix"]}_interfaces') - statsd.gauge('checks', result['checks']) - statsd.gauge('input', result['input']) - statsd.gauge('created', result['created']) - statsd.gauge('updated', result['updated']) - statsd.gauge('deleted', result['deleted']) + statsd.gauge('checks', result['interfaces']['checks']) + statsd.gauge('input', result['interfaces']['input']) + statsd.gauge('created', result['interfaces']['created']) + statsd.gauge('updated', result['interfaces']['updated']) + statsd.gauge('deleted', result['interfaces']['deleted']) - return statsd_config + jsonschema.validate(result, REFRESH_RESULT_SCHEMA) # sanity + return result def _validate_config(_ctx, _param, file): """ @@ -95,7 +127,7 @@ def cli(config, force): """ Update BRIAN snmp checks based on Inventory Provider data. """ - refresh(config, force) + logger.info(json.dumps(refresh(config, force))) if __name__ == '__main__': diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000000000000000000000000000000000000..efea063542a57dd21192254370ab61c5dc45f421 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,6 @@ + +HTTP API +=================================================== + +.. automodule:: brian_polling_manager.api + diff --git a/docs/source/index.rst b/docs/source/index.rst index 4ced46d553c7bfc2978b28ca97fa62a94d276141..006d51f03004ee54b7dd4a21996274b94e633c8a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,3 +17,4 @@ Sensu checks for polling the data required by BRIAN. :caption: Contents: main + api diff --git a/requirements.txt b/requirements.txt index 14c092051717d2a1d81ef626c0e33e155083fb67..a5a0af502b4e289530624c546268288480910240 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ click jsonschema requests statsd +flask pytest responses diff --git a/setup.py b/setup.py index cf6c751e088fecda7fd1a4f1df24acc0c80a578a..59ddd2c76c53cfbd00d4366fd7c9ac62f482ac9d 100644 --- a/setup.py +++ b/setup.py @@ -12,11 +12,12 @@ setup( 'click', 'requests', 'jsonschema', - 'statsd' + 'statsd', + 'flask' ], entry_points={ 'console_scripts': [ - 'brian-polling-manager=brian_polling_manager.cli:main' + 'brian-polling-manager=brian_polling_manager.main:cli' ] }, include_package_data=True,