# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or # implied. # See the License for the specific language governing permissions and # limitations under the License. import eventlet eventlet.monkey_patch() import functools import os import re import ssl import sys import flask from oslo_config import cfg from oslo_log import log from oslo_utils import uuidutils import werkzeug from ironic_inspector import db from ironic_inspector.common.i18n import _, _LC, _LE, _LI, _LW from ironic_inspector.common import swift from ironic_inspector import conf # noqa from ironic_inspector import firewall from ironic_inspector import introspect from ironic_inspector import node_cache from ironic_inspector.plugins import base as plugins_base from ironic_inspector import process from ironic_inspector import rules from ironic_inspector import utils CONF = cfg.CONF app = flask.Flask(__name__) LOG = utils.getProcessingLogger(__name__) MINIMUM_API_VERSION = (1, 0) CURRENT_API_VERSION = (1, 3) _MIN_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Minimum-Version' _MAX_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Maximum-Version' _VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Version' _LOGGING_EXCLUDED_KEYS = ('logs',) def _format_version(ver): return '%d.%d' % ver _DEFAULT_API_VERSION = _format_version(MINIMUM_API_VERSION) def error_response(exc, code=500): res = flask.jsonify(error={'message': str(exc)}) res.status_code = code LOG.debug('Returning error to client: %s', exc) return res def convert_exceptions(func): @functools.wraps(func) def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except utils.Error as exc: return error_response(exc, exc.http_code) except werkzeug.exceptions.HTTPException as exc: return error_response(exc, exc.code or 400) except Exception as exc: LOG.exception(_LE('Internal server error')) msg = _('Internal server error') if CONF.debug: msg += ' (%s): %s' % (exc.__class__.__name__, exc) return error_response(msg) return wrapper @app.before_request def check_api_version(): requested = flask.request.headers.get(_VERSION_HEADER, _DEFAULT_API_VERSION) try: requested = tuple(int(x) for x in requested.split('.')) except (ValueError, TypeError): return error_response(_('Malformed API version: expected string ' 'in form of X.Y'), code=400) if requested < MINIMUM_API_VERSION or requested > CURRENT_API_VERSION: return error_response(_('Unsupported API version %(requested)s, ' 'supported range is %(min)s to %(max)s') % {'requested': _format_version(requested), 'min': _format_version(MINIMUM_API_VERSION), 'max': _format_version(CURRENT_API_VERSION)}, code=406) @app.after_request def add_version_headers(res): res.headers[_MIN_VERSION_HEADER] = '%s.%s' % MINIMUM_API_VERSION res.headers[_MAX_VERSION_HEADER] = '%s.%s' % CURRENT_API_VERSION return res def create_link_object(urls): links = [] for url in urls: links.append({"rel": "self", "href": os.path.join(flask.request.url_root, url)}) return links def generate_resource_data(resources): data = [] for resource in resources: item = {} item['name'] = str(resource).split('/')[-1] item['links'] = create_link_object([str(resource)[1:]]) data.append(item) return data @app.route('/', methods=['GET']) @convert_exceptions def api_root(): versions = [ { "status": "CURRENT", "id": '%s.%s' % CURRENT_API_VERSION, }, ] for version in versions: version['links'] = create_link_object( ["v%s" % version['id'].split('.')[0]]) return flask.jsonify(versions=versions) @app.route('/', methods=['GET']) @convert_exceptions def version_root(version): pat = re.compile("^\/%s\/[^\/]*?$" % version) resources = [] for url in app.url_map.iter_rules(): if pat.match(str(url)): resources.append(url) if not resources: raise utils.Error(_('Version not found.'), code=404) return flask.jsonify(resources=generate_resource_data(resources)) @app.route('/v1/continue', methods=['POST']) @convert_exceptions def api_continue(): data = flask.request.get_json(force=True) if not isinstance(data, dict): raise utils.Error(_('Invalid data: expected a JSON object, got %s') % data.__class__.__name__) logged_data = {k: (v if k not in _LOGGING_EXCLUDED_KEYS else '') for k, v in data.items()} LOG.debug("Received data from the ramdisk: %s", logged_data, data=data) return flask.jsonify(process.process(data)) # TODO(sambetts) Add API discovery for this endpoint @app.route('/v1/introspection/', methods=['GET', 'POST']) @convert_exceptions def api_introspection(uuid): utils.check_auth(flask.request) if not uuidutils.is_uuid_like(uuid): raise utils.Error(_('Invalid UUID value'), code=400) if flask.request.method == 'POST': new_ipmi_password = flask.request.args.get('new_ipmi_password', type=str, default=None) if new_ipmi_password: new_ipmi_username = flask.request.args.get('new_ipmi_username', type=str, default=None) new_ipmi_credentials = (new_ipmi_username, new_ipmi_password) else: new_ipmi_credentials = None introspect.introspect(uuid, new_ipmi_credentials=new_ipmi_credentials, token=flask.request.headers.get('X-Auth-Token')) return '', 202 else: node_info = node_cache.get_node(uuid) return flask.json.jsonify(finished=bool(node_info.finished_at), error=node_info.error or None) @app.route('/v1/introspection//abort', methods=['POST']) @convert_exceptions def api_introspection_abort(uuid): utils.check_auth(flask.request) if not uuidutils.is_uuid_like(uuid): raise utils.Error(_('Invalid UUID value'), code=400) introspect.abort(uuid, token=flask.request.headers.get('X-Auth-Token')) return '', 202 @app.route('/v1/introspection//data', methods=['GET']) @convert_exceptions def api_introspection_data(uuid): utils.check_auth(flask.request) if CONF.processing.store_data == 'swift': res = swift.get_introspection_data(uuid) return res, 200, {'Content-Type': 'application/json'} else: return error_response(_('Inspector is not configured to store data. ' 'Set the [processing] store_data ' 'configuration option to change this.'), code=404) def rule_repr(rule, short): result = rule.as_dict(short=short) result['links'] = [{ 'href': flask.url_for('api_rule', uuid=result['uuid']), 'rel': 'self' }] return result @app.route('/v1/rules', methods=['GET', 'POST', 'DELETE']) @convert_exceptions def api_rules(): utils.check_auth(flask.request) if flask.request.method == 'GET': res = [rule_repr(rule, short=True) for rule in rules.get_all()] return flask.jsonify(rules=res) elif flask.request.method == 'DELETE': rules.delete_all() return '', 204 else: body = flask.request.get_json(force=True) if body.get('uuid') and not uuidutils.is_uuid_like(body['uuid']): raise utils.Error(_('Invalid UUID value'), code=400) rule = rules.create(conditions_json=body.get('conditions', []), actions_json=body.get('actions', []), uuid=body.get('uuid'), description=body.get('description')) return flask.jsonify(rule_repr(rule, short=False)) @app.route('/v1/rules/', methods=['GET', 'DELETE']) @convert_exceptions def api_rule(uuid): utils.check_auth(flask.request) if flask.request.method == 'GET': rule = rules.get(uuid) return flask.jsonify(rule_repr(rule, short=False)) else: rules.delete(uuid) return '', 204 @app.errorhandler(404) def handle_404(error): return error_response(error, code=404) def periodic_update(period): # pragma: no cover while True: LOG.debug('Running periodic update of filters') try: firewall.update_filters() except Exception: LOG.exception(_LE('Periodic update failed')) eventlet.greenthread.sleep(period) def periodic_clean_up(period): # pragma: no cover while True: LOG.debug('Running periodic clean up of node cache') try: if node_cache.clean_up(): firewall.update_filters() sync_with_ironic() except Exception: LOG.exception(_LE('Periodic clean up of node cache failed')) eventlet.greenthread.sleep(period) def sync_with_ironic(): ironic = utils.get_client() # TODO(yuikotakada): pagination ironic_nodes = ironic.node.list(limit=0) ironic_node_uuids = {node.uuid for node in ironic_nodes} node_cache.delete_nodes_not_in_list(ironic_node_uuids) def init(): if utils.get_auth_strategy() != 'noauth': utils.add_auth_middleware(app) else: LOG.warning(_LW('Starting unauthenticated, please check' ' configuration')) if CONF.processing.store_data == 'none': LOG.warning(_LW('Introspection data will not be stored. Change ' '"[processing] store_data" option if this is not the ' 'desired behavior')) elif CONF.processing.store_data == 'swift': LOG.info(_LI('Introspection data will be stored in Swift in the ' 'container %s'), CONF.swift.container) db.init() try: hooks = [ext.name for ext in plugins_base.processing_hooks_manager()] except KeyError as exc: # stevedore raises KeyError on missing hook LOG.critical(_LC('Hook %s failed to load or was not found'), str(exc)) sys.exit(1) LOG.info(_LI('Enabled processing hooks: %s'), hooks) if CONF.firewall.manage_firewall: firewall.init() period = CONF.firewall.firewall_update_period utils.spawn_n(periodic_update, period) if CONF.timeout > 0: period = CONF.clean_up_period utils.spawn_n(periodic_clean_up, period) else: LOG.warning(_LW('Timeout is disabled in configuration')) def create_ssl_context(): if not CONF.use_ssl: return MIN_VERSION = (2, 7, 9) if sys.version_info < MIN_VERSION: LOG.warning(_LW('Unable to use SSL in this version of Python: ' '%{current}, please ensure your version of Python is ' 'greater than %{min} to enable this feature.'), {'current': '.'.join(map(str, sys.version_info[:3])), 'min': '.'.join(map(str, MIN_VERSION))}) return context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) if CONF.ssl_cert_path and CONF.ssl_key_path: try: context.load_cert_chain(CONF.ssl_cert_path, CONF.ssl_key_path) except IOError as exc: LOG.warning(_LW('Failed to load certificate or key from defined ' 'locations: %{cert} and %{key}, will continue to ' 'run with the default settings: %{exc}'), {'cert': CONF.ssl_cert_path, 'key': CONF.ssl_key_path, 'exc': exc}) except ssl.SSLError as exc: LOG.warning(_LW('There was a problem with the loaded certificate ' 'and key, will continue to run with the default ' 'settings: %s'), exc) return context def main(args=sys.argv[1:]): # pragma: no cover log.register_options(CONF) CONF(args, project='ironic-inspector') log.set_defaults(default_log_levels=[ 'sqlalchemy=WARNING', 'keystoneclient=INFO', 'iso8601=WARNING', 'requests=WARNING', 'urllib3.connectionpool=WARNING', 'keystonemiddleware=WARNING', 'swiftclient=WARNING', 'keystoneauth=WARNING', 'ironicclient=WARNING' ]) log.setup(CONF, 'ironic_inspector') app_kwargs = {'host': CONF.listen_address, 'port': CONF.listen_port} context = create_ssl_context() if context: app_kwargs['ssl_context'] = context init() try: app.run(**app_kwargs) finally: firewall.clean_up()