#!/usr/bin/python import atexit import getopt import inspect import logging import os import re import sys from logging.handlers import RotatingFileHandler import eventlet.debug from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from flask import Flask, request, g from flask_babel import Babel, get_locale from pymongo import MongoClient from werkzeug.exceptions import default_exceptions, HTTPException from .authorisation import authorize_request from .infrastructure import CfgEngine from .configuration import config from .core import AppInitialisationError from .iam import RbacMixin from .model import Model from .util import default_json_serializer, create_custom_error try: import simplejson as json except ImportError: import json try: from flask import _app_ctx_stack as stack except ImportError: from flask import _request_ctx_stack as stack class AppKernelJSONEncoder(json.JSONEncoder): def default(self, obj): try: return default_json_serializer(obj) except TypeError: pass return json.JSONEncoder.default(self, obj) def get_option_value(option_dict, opts): for opt, arg in opts: if opt in option_dict: return arg or True return None def get_cmdline_options(): # working dir is also available on: self.app.root_path argv = sys.argv[1:] opts, args = getopt.getopt(argv, 'c:dw:h:', ['config-dir=', 'development', 'working-dir=', 'db-host=']) cwd = os.path.dirname(os.path.realpath(sys.argv[0])) # -- config directory config_dir_param = get_option_value(('-c', '--config-dir'), opts) if config_dir_param: cfg_dir = '{}/'.format(str(config_dir_param).rstrip('/')) cfg_dir = os.path.expanduser(cfg_dir) if not os.path.isdir(cfg_dir) or not os.access(cfg_dir, os.W_OK): raise AppInitialisationError('The config directory [{}] is not found/not writable.'.format(cfg_dir)) else: cfg_dir = None # -- working directory working_dir_param = get_option_value(('-w', '--working-dir'), opts) if working_dir_param: cwd = os.path.expanduser('{}/'.format(str(config_dir_param).rstrip('/'))) if not os.path.isdir(cwd) or not os.access(cwd, os.W_OK): raise AppInitialisationError('The working directory[{}] is not found/not writable.'.format(cwd)) else: cwd = f'{cwd.rstrip("/")}' development = get_option_value(('-d', '--development'), opts) db_host = get_option_value(('-h', '--db-host'), opts) return { 'cfg_dir': cfg_dir, 'development': development, 'cwd': cwd, 'db': db_host } class ResourceController(RbacMixin): def __init__(self, cls): super().__init__(cls) self.cls = cls class AppKernelEngine(object): def __init__(self, app_id: str, app: Flask = None, root_url: str = '/', log_level=logging.DEBUG, cfg_dir: str = None, development: bool = False, enable_defaults: bool = True): """ Initialiser of Flask Engine. :param app: the Flask App :type app: Flask :param root_url: the url where the service are exposed to. :type root_url: str :param log_level: the level of log :param cfg_dir: the directory containing the cfg.yml file. If not provided it will be taken from the command line or from current working dir; :param development: the system will be initialised in development mode if True. If None, it will try to read the value as command line parameter or default to false; :type log_level: logging """ assert app_id is not None, 'The app_id must be provided' assert re.match('[A-Za-z0-9-_]', app_id), 'The app_id must be a single word, no space or special characters except - or _ .' self.app: Flask = app or Flask(app_id) assert self.app is not None, 'The Flask App must be provided as init parameter.' try: config.service_registry = {} self.before_request_functions = [] self.after_request_functions = [] self.app_id = app_id self.root_url = root_url self.__configure_flask_app() self.__init_web_layer() self.cmd_line_options = get_cmdline_options() self.cfg_dir = cfg_dir or self.cmd_line_options.get('cfg_dir') self.cfg_engine = CfgEngine(self.cfg_dir, optional=enable_defaults) config.cfg_engine = self.cfg_engine self.__init_babel() self.__init_cross_cutting_concerns() self.__init_event_loop() self.development = development or self.cmd_line_options.get('development') cwd = self.cmd_line_options.get('cwd') self.init_logger(log_folder=cwd, level=log_level) # -- initialisation # this can raise false positives if a bit of code running # longer than 1 seconds. # the timeout can be increased by adding the parameter: # resolution=3, where the value 3 represents 3 seconds. eventlet.debug.hub_blocking_detection(True, resolution=3) atexit.register(self.shutdown_hook) if hasattr(app, 'teardown_appcontext'): app.teardown_appcontext(self.teardown) elif hasattr(app, 'teardown_request'): app.teardown_request(self.teardown) # -- database host db_host = self.cmd_line_options.get('db') or self.cfg_engine.get('appkernel.mongo.host', 'localhost') db_name = self.cfg_engine.get('appkernel.mongo.db', 'app') self.mongo_client = MongoClient(host=db_host) config.mongo_database = self.mongo_client[db_name] config.flask_app: Flask = self.app config.app_engine = self except (AppInitialisationError, AssertionError) as init_err: # print >> sys.stderr, self.app.logger.error(init_err.message) sys.exit(-1) def enable_security(self, authorisation_method=None): self.enable_pki() if not authorisation_method: authorisation_method = authorize_request self.add_before_request_function(authorisation_method) config.security_enabled = True return self def enable_pki(self): if not hasattr(self.app, 'public_key'): self.__init_crypto() def add_before_request_function(self, func): self.before_request_functions.append(func) def add_after_request_function(self, func): self.after_request_functions.append(func) @staticmethod def __init_event_loop(): # todo: implement event loop # config.event_loop = asyncio.get_event_loop() pass def shutdown_eventloop(): if 'event_loop' in config.__dict__ and config.event_loop and config.event_loop.is_running(): logging.info('shutting down the event loop.') config.event_loop.shutdown_asyncgens() config.event_loop.stop() atexit.register(shutdown_eventloop) def __init_cross_cutting_concerns(self): def create_function_chain_executor(chain): def function_chain_executor(): for func in chain: return func() return function_chain_executor # todo: journaling request responses # todo: rate limiting self.app.before_request(create_function_chain_executor(self.before_request_functions)) # todo: add after request processor # self.app.after_request(create_function_chain_executor(self.after_request_functions)) def __init_crypto(self): # https://stackoverflow.com/questions/29650495/how-to-verify-a-jwt-using-python-pyjwt-with-public-key with self.app.app_context(): with open('{}/keys/appkernel.pem'.format(self.cfg_dir), "rb") as key_file: private_key = serialization.load_pem_private_key( key_file.read(), password=None, backend=default_backend() ) config.private_key = private_key with open('{}/keys/appkernel.pub'.format(self.cfg_dir), 'rb') as key_file: public_key = serialization.load_pem_public_key( key_file.read(), backend=default_backend() ) config.public_key = public_key def __init_babel(self): self.babel = Babel(self.app) # translations = Translations.load('translations') # translations.merge(Translations.load()) # todo: support for multiple plugins supported_languages = [] for supported_lang in self.cfg_engine.get('appkernel.i18n.languages', ['en-US']): supported_languages.append(supported_lang) if '-' in supported_lang: supported_languages.append(supported_lang.split('-')[0]) def get_current_locale(): with self.app.app_context(): best_match = request.accept_languages.best_match(supported_languages, default='en') return best_match.replace('-', '_') self.babel.localeselector(get_current_locale) # catalogs = gettext.find('locale', 'locale', all=True) # self.logger.info('Using message catalogs: {}'.format(catalogs)) @property def logger(self): return self.app.logger def run(self): self.app.logger.info('===== Starting {} ====='.format(self.app_id)) self.app.logger.debug(f'--> registered routes:\n {self.app.url_map}') if self.development: self.app.logger.info(f'--> initialising in development mode...') self.app.run(debug=self.development, threaded=True) # todo: make the threading and deployment configurable # self.app.run(debug=self.development, processes=8) else: try: from gevent.pywsgi import WSGIServer, LoggingLogAdapter port = self.cfg_engine.get('appkernel.server.port', 5000) binding_address = self.cfg_engine.get('appkernel.server.address', '') backlog = self.cfg_engine.get('appkernel.server.backlog', 256) logging_adapter = LoggingLogAdapter(self.app.logger) self.http_server = WSGIServer((binding_address, port), application=self.app, backlog=backlog, log=logging_adapter, error_log=logging_adapter) shutdown_timeout = self.cfg_engine.get('appkernel.server.shutdown_timeout', 10) self.app.logger.info(f'--> starting production mode |host: {binding_address}|port: {port}|backlog: {backlog}') print(f'=== starting server ===') self.http_server.serve_forever(stop_timeout=shutdown_timeout) except ImportError: self.app.logger.warning( '--> falling back to the builtin development server (since gevent is missing / issue: pip install gevent') self.app.run(debug=self.development, threaded=True) def shutdown_hook(self): if config and hasattr(config, 'mongo_database') and config.mongo_database: self.mongo_client.close() if hasattr(self, 'app') and self.app and hasattr(self.app, 'logger') and self.app.logger: self.app.logger.info('======= Shutting Down {} ======='.format(self.app_id)) # no need for the following code snippet while the http_server.serve_forever() is used # if hasattr(self, 'http_server'): # self.http_server.stop(10) @staticmethod def get_cmdline_options(): # working dir is also available on: self.app.root_path argv = sys.argv[1:] opts, args = getopt.getopt(argv, 'c:dw:', ['config-dir=', 'development', 'working-dir=']) # -- config directory config_dir_provided, config_dir_param = AppKernelEngine.is_option_provided(('-c', '--config-dir'), opts, args) cwd = os.path.dirname(os.path.realpath(sys.argv[0])) if config_dir_provided: cfg_dir = '{}/'.format(str(config_dir_param).rstrip('/')) cfg_dir = os.path.expanduser(cfg_dir) if not os.path.isdir(cfg_dir) or not os.access(cfg_dir, os.W_OK): raise AppInitialisationError('The config directory [{}] is not found/not writable.'.format(cfg_dir)) else: cfg_dir = None # -- working directory working_dir_provided, working_dir_param = AppKernelEngine.is_option_provided(('-w', '--working-dir'), opts, args) if working_dir_provided: cwd = os.path.expanduser('{}/'.format(str(config_dir_param).rstrip('/'))) if not os.path.isdir(cwd) or not os.access(cwd, os.W_OK): raise AppInitialisationError('The working directory[{}] is not found/not writable.'.format(cwd)) else: cwd = '{}/../'.format(cwd.rstrip('/')) development, param = AppKernelEngine.is_option_provided(('-d', '--development'), opts, args) return { 'cfg_dir': cfg_dir, 'development': development, 'cwd': cwd } def __configure_flask_app(self): if hasattr(self.app, 'teardown_appcontext'): self.app.teardown_appcontext(self.teardown) else: self.app.teardown_request(self.teardown) if not hasattr(self.app, 'extensions'): self.app.extensions = {} self.app.extensions['appkernel'] = self def __init_web_layer(self): self.app.json_encoder = AppKernelJSONEncoder self.app.register_error_handler(Exception, self.generic_error_handler) for code in default_exceptions.keys(): # add a default error handler for everything is unhandled self.app.register_error_handler(code, self.generic_error_handler) def set_locale_on_request(): g.locale = str(get_locale()) self.app.before_request(set_locale_on_request) def init_logger(self, log_folder, level=logging.DEBUG): assert log_folder is not None, 'The log folder must be provided.' if self.development: formatter = logging.Formatter("%(levelname)s - %(message)s") handler = logging.StreamHandler() handler.setLevel(level) self._enable_werkzeug_logger(handler) else: # self.cfg_engine.get_value_for_section() # log_format = ' in %(module)s [%(pathname)s:%(lineno)d]:\n%(message)s' formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s:%(lineno)d - %(message)s") max_bytes = self.cfg_engine.get('appkernel.logging.max_size', 10485760) backup_count = self.cfg_engine.get('appkernel.logging.backup_count', 3) file_name = self.cfg_engine.get( 'appkernel.logging.file_name') or f"{self.app_id.replace(' ', '_').lower()}.log" handler = RotatingFileHandler('{}/{}'.format(log_folder, file_name), maxBytes=max_bytes, backupCount=backup_count) # handler = TimedRotatingFileHandler('logs/foo.log', when='midnight', interval=1) handler.setLevel(level) handler.setFormatter(formatter) self.app.logger.setLevel(level) # self.app.logger.addHandler(handler) self.app.logger.handlers = [handler] self.app.logger.info('Logger initialised') @staticmethod def _enable_werkzeug_logger(handler): logger = logging.getLogger('werkzeug') logger.setLevel(logging.DEBUG) logger.addHandler(handler) def generic_error_handler(self, ex: Exception = None, upstream_service: str = None): """ Takes a generic exception and returns a json error message which will be returned to the client :param ex: the exception which is reported by this method :param upstream_service: the servicr name which generated this error :return: """ code = (ex.code if isinstance(ex, HTTPException) else 500) if ex and code != 404: msg = '{}/{}'.format(ex.__class__.__name__, ex.description if isinstance(ex, HTTPException) else str(ex)) self.logger.exception('generic error handler: {}/{}'.format(ex.__class__.__name__, str(ex))) elif ex and code == 404: msg = '{} ({} {}): {}'.format(ex.__class__.__name__, request.method, request.url, ex.description if isinstance(ex, HTTPException) else str(ex)) self.logger.exception('generic error handler: {}/{}'.format(ex.__class__.__name__, str(ex))) else: msg = 'Generic server error.' self.logger.warning('generic error handler: {}/{}'.format(ex.__class__.__name__, str(ex))) return create_custom_error(code, msg, upstream_service=upstream_service) def teardown(self, exception): """ context teardown based deallocation :param exception: :type exception: Exception :return: """ if exception is not None: self.app.logger.warning(exception.message if hasattr(exception, 'message') else str(exception)) def register(self, service_class_or_instance, url_base=None, methods=['GET'], enable_hateoas=True) -> ResourceController: """ :param service_class_or_instance: :param url_base: :param methods: :param enable_hateoas: :return: :rtype: Service """ if inspect.isclass(service_class_or_instance): assert issubclass(service_class_or_instance, ( Model)), 'Only subclasses of Model can be registered as class. If you want to register a controller, please use its instance.' from appkernel.service import expose_service expose_service(service_class_or_instance, self, url_base or self.root_url, methods=methods, enable_hateoas=enable_hateoas) return ResourceController(service_class_or_instance)