Source code for ironic_inspector.main

# 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 functools
import os
import random
import re

import flask
import microversion_parse as mvp
from oslo_utils import strutils
from oslo_utils import uuidutils
from werkzeug.middleware import proxy_fix

from ironic_inspector import api_tools
from ironic_inspector.common import context
from ironic_inspector.common import coordination
from ironic_inspector.common.i18n import _
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector.common import rpc
from ironic_inspector.conductor import manager
import ironic_inspector.conf
from ironic_inspector.conf import opts as conf_opts
from ironic_inspector import node_cache
from ironic_inspector import process
from ironic_inspector import rules
from ironic_inspector import utils


CONF = ironic_inspector.conf.CONF


_app = flask.Flask(__name__)
_app.url_map.strict_slashes = False
_wsgi_app = _app.wsgi_app

LOG = utils.getProcessingLogger(__name__)

MINIMUM_API_VERSION = (1, 0)
CURRENT_API_VERSION = (1, 18)
DEFAULT_API_VERSION = CURRENT_API_VERSION
_LOGGING_EXCLUDED_KEYS = ('logs',)


def _init_middleware():
    """Initialize WSGI middleware.

    :returns: None
    """

    # Ensure original root app is restored and wrap it with ProxyFix,
    # respecting only the last entry in each header if it contains a list of
    # values. The following headers are respected: X-Forwarded-For,
    # X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
    # X-Forwarded-Prefix (the last one sets SCRIPT_NAME environment variable
    # that is used to construct links).
    _app.wsgi_app = proxy_fix.ProxyFix(
        _wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
    if CONF.auth_strategy == 'keystone':
        utils.add_auth_middleware(_app)
    elif CONF.auth_strategy == 'http_basic':
        utils.add_basic_auth_middleware(_app)
    else:
        LOG.warning('Starting unauthenticated, please check'
                    ' configuration')
    if CONF.healthcheck.enabled:
        utils.add_healthcheck_middleware(_app)
    utils.add_cors_middleware(_app)


[docs] def get_app(): """Get the flask instance.""" with _app.app_context(): _init_middleware() start_coordinator() return _app
# TODO(kaifeng) Extract rpc related code into a rpcapi module
[docs] def get_random_topic(): coordinator = coordination.get_coordinator(prefix='api') members = coordinator.get_members() hosts = [] for member in members: # NOTE(kaifeng) recomposite host in case it contains '.' parts = member.decode('ascii').split('.') if len(parts) < 3: LOG.warning('Found invalid member %s', member) continue if parts[1] == 'conductor': hosts.append('.'.join(parts[2:])) if not hosts: raise utils.NoAvailableConductor('No available conductor service') topic = '%s.%s' % (manager.MANAGER_TOPIC, random.choice(hosts)) return topic
[docs] def get_client_compat(): if CONF.standalone: return rpc.get_client() topic = get_random_topic() return rpc.get_client(topic)
def _get_version(): ver = flask.request.headers.get(conf_opts.VERSION_HEADER, _DEFAULT_API_VERSION) try: requested = _DEFAULT_API_VERSION if ver: if ver.lower() == 'latest': requested = CURRENT_API_VERSION else: requested = mvp.parse_version_string(ver) if len(requested) != 2: raise ValueError except (ValueError, TypeError, AttributeError): return error_response(_('Malformed API version: expected string ' 'in form of X.Y or latest'), code=400) return requested def _format_version(ver): return '%d.%d' % ver _DEFAULT_API_VERSION = _format_version(DEFAULT_API_VERSION)
[docs] 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 _generate_empty_response(code): """Change the content mime type to text/plain. :param code: The HTTP response code as an integer. :returns: An empty flask response object with the requested return code. """ # NOTE(TheJulia): Explicitly set a mime type and body on the # response as some proxies view the lack of a mime type as a # failure when the request was actually successful. # Strictly speaking, 204s should have no body, where as 202's # don't strictly require or expect content, but content can # be included for user friendly response bodies. if code == 204: response = flask.make_response('', code) # NOTE(TheJulia): Explicitly set a content length to zero. # as this is the *lesser* of all evils until # https://github.com/eventlet/eventlet/issues/746 # is resolved. response.headers['Content-Length'] = 0 response.mimetype = 'text/plain' def _return_headers(cls): # Dynamically re-maps over get_wsgi_headers() method in # which werkzueg strips the content-length and sets us up # for eventlet forcing in a Transfer-Encoding: chunked # header. return response.headers response.get_wsgi_headers = _return_headers else: # Send an empty dictionary to set a mimetype, and ultimately # with this being a rest API we can, at some point, choose to # convey some sort of status response back in the message # body. response = flask.make_response({}, code) return response
[docs] 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 Exception as exc: LOG.exception('Internal server error') msg = _('Internal server error') if CONF.debug: msg += ' (%s): %s' % (exc.__class__.__name__, exc) return error_response(msg) return wrapper
[docs] def start_coordinator(): """Create a coordinator instance for non-standalone case.""" if not CONF.standalone: coordinator = coordination.get_coordinator(prefix='api') coordinator.start(heartbeat=False) LOG.info('Sucessfully created coordinator.')
[docs] @_app.before_request def check_api_version(): requested = _get_version() 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)
[docs] @_app.after_request def add_version_headers(res): res.headers[conf_opts.MIN_VERSION_HEADER] = '%s.%s' % MINIMUM_API_VERSION res.headers[conf_opts.MAX_VERSION_HEADER] = '%s.%s' % CURRENT_API_VERSION return res
[docs] def generate_resource_data(resources): data = [] for resource in resources: item = {} item['name'] = str(resource).rstrip('/').split('/')[-1] item['links'] = create_link_object([str(resource)[1:]]) data.append(item) return data
[docs] def generate_introspection_status(node): """Return a dict representing current node status. :param node: a NodeInfo instance :return: dictionary """ started_at = node.started_at.isoformat() finished_at = node.finished_at.isoformat() if node.finished_at else None status = {} status['uuid'] = node.uuid status['finished'] = bool(node.finished_at) status['state'] = node.state status['started_at'] = started_at status['finished_at'] = finished_at status['error'] = node.error status['links'] = create_link_object( ["v%s/introspection/%s" % (CURRENT_API_VERSION[0], node.uuid)]) return status
[docs] def api(path, is_public_api=False, rule=None, verb_to_rule_map=None, **flask_kwargs): """Decorator to wrap api methods. Performs flask routing, exception conversion, generation of oslo context for request and API access policy enforcement. :param path: flask app route path :param is_public_api: whether this API path should be treated as public, with minimal access enforcement :param rule: API access policy rule to enforce. If rule is None, the 'default' policy rule will be enforced, which is "deny all" if not overridden in policy config file. :param verb_to_rule_map: if both rule and this are given, defines mapping between http verbs (uppercase) and strings to format the 'rule' string with :param kwargs: all the rest kwargs are passed to flask app.route """ def outer(func): @_app.route(path, **flask_kwargs) @convert_exceptions @functools.wraps(func) def wrapper(*args, **kwargs): flask.request.context = context.RequestContext.from_environ( flask.request.environ, is_public_api=is_public_api) if verb_to_rule_map and rule: policy_rule = rule.format( verb_to_rule_map[flask.request.method.upper()]) else: policy_rule = rule utils.check_auth(flask.request, rule=policy_rule) return func(*args, **kwargs) return wrapper return outer
[docs] @api('/', rule='introspection', is_public_api=True, methods=['GET']) 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)
[docs] @api('/<version>', rule='introspection:version', is_public_api=True, methods=['GET']) def version_root(version): pat = re.compile(r'^\/%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 %s not found.') % version, code=404) return flask.jsonify(resources=generate_resource_data(resources))
[docs] @api('/v1/continue', rule="introspection:continue", is_public_api=True, methods=['POST']) 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 '<hidden>') for k, v in data.items()} LOG.debug("Received data from the ramdisk: %s", logged_data, data=data) client = get_client_compat() result = client.call({}, 'do_continue', data=data) return flask.jsonify(result)
# TODO(sambetts) Add API discovery for this endpoint
[docs] @api('/v1/introspection/<node_id>', rule="introspection:{}", verb_to_rule_map={'GET': 'status', 'POST': 'start'}, methods=['GET', 'POST']) def api_introspection(node_id): if flask.request.method == 'POST': args = flask.request.args manage_boot = args.get('manage_boot', 'True') try: manage_boot = strutils.bool_from_string(manage_boot, strict=True) except ValueError: raise utils.Error(_('Invalid boolean value for manage_boot: %s') % manage_boot, code=400) if manage_boot and not CONF.can_manage_boot: raise utils.Error(_('Managed boot is requested, but this ' 'installation cannot manage boot (' '(can_manage_boot set to False)'), code=400) client = get_client_compat() client.call({}, 'do_introspection', node_id=node_id, manage_boot=manage_boot, token=flask.request.headers.get('X-Auth-Token')) return _generate_empty_response(202) else: node_info = node_cache.get_node(node_id) return flask.json.jsonify(generate_introspection_status(node_info))
[docs] @api('/v1/introspection', rule='introspection:status', methods=['GET']) def api_introspection_statuses(): nodes = node_cache.get_node_list( marker=api_tools.marker_field(), limit=api_tools.limit_field(default=CONF.api_max_limit), state=api_tools.state_field() ) data = { 'introspection': [generate_introspection_status(node) for node in nodes] } return flask.json.jsonify(data)
[docs] @api('/v1/introspection/<node_id>/abort', rule="introspection:abort", methods=['POST']) def api_introspection_abort(node_id): client = get_client_compat() client.call({}, 'do_abort', node_id=node_id, token=flask.request.headers.get('X-Auth-Token')) return _generate_empty_response(202)
def _get_data(node_id, processed): try: if not uuidutils.is_uuid_like(node_id): node = ir_utils.get_node(node_id, fields=['uuid']) node_id = node.id res = process.get_introspection_data(node_id, processed=processed) return res, 200, {'Content-Type': 'application/json'} except utils.IntrospectionDataStoreDisabled: return error_response(_('Inspector is not configured to store data. ' 'Set the [processing]store_data ' 'configuration option to change this.'), code=404)
[docs] @api('/v1/introspection/<node_id>/data', rule="introspection:data", methods=['GET']) def api_introspection_data(node_id): return _get_data(node_id, True)
[docs] @api('/v1/introspection/<node_id>/data/unprocessed', rule="introspection:data", methods=['GET']) def api_introspection_unprocessed_data(node_id): return _get_data(node_id, False)
[docs] @api('/v1/introspection/<node_id>/data/unprocessed', rule="introspection:reapply", methods=['POST']) def api_introspection_reapply(node_id): data = None if flask.request.content_length: try: data = flask.request.get_json(force=True) except Exception: raise utils.Error( _('Invalid data: expected a JSON object, got %s') % data) if not isinstance(data, dict): raise utils.Error( _('Invalid data: expected a JSON object, got %s') % data.__class__.__name__) LOG.debug("Received reapply data from request", data=data) if not uuidutils.is_uuid_like(node_id): node = ir_utils.get_node(node_id, fields=['uuid']) node_id = node.id client = get_client_compat() client.call({}, 'do_reapply', node_uuid=node_id, data=data) return _generate_empty_response(202)
[docs] def rule_repr(rule, short): result = rule.as_dict(short=short) result['links'] = [{ 'href': flask.url_for('api_rule', uuid=result['uuid']).rstrip('/'), 'rel': 'self' }] return result
[docs] @api('/v1/rules', rule="introspection:rule:{}", verb_to_rule_map={'GET': 'get', 'POST': 'create', 'DELETE': 'delete'}, methods=['GET', 'POST', 'DELETE']) def api_rules(): 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 _generate_empty_response(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) if body.get('scope') and len(body.get('scope')) > 255: raise utils.Error( _("Invalid scope: the length of the scope should be within " "255 characters"), code=400) rule = rules.create(conditions_json=body.get('conditions', []), actions_json=body.get('actions', []), uuid=body.get('uuid'), description=body.get('description'), scope=body.get('scope')) response_code = (200 if _get_version() < (1, 6) else 201) return flask.make_response( flask.jsonify(rule_repr(rule, short=False)), response_code)
[docs] @api('/v1/rules/<uuid>', rule="introspection:rule:{}", verb_to_rule_map={'GET': 'get', 'DELETE': 'delete'}, methods=['GET', 'DELETE']) def api_rule(uuid): if flask.request.method == 'GET': rule = rules.get(uuid) return flask.jsonify(rule_repr(rule, short=False)) else: rules.delete(uuid) return _generate_empty_response(204)
[docs] @_app.errorhandler(404) def handle_404(error): return error_response(error, code=404)