ironic-inspector/ironic_inspector/main.py
Kaifeng Wang 293b0c7c15 Split API and conductor services
This patch splits API and conductor services for ironic-inspector.
Previous patch utilized lock from tooz coordinator, this patch adds
a coordinator wrapper for easier usage and further introduces group
interfaces.

Each conductor service will join a predefined group to mark it's
availability, on each request, API service will query members from
the group and randomly choose on of them, create desiginated topic
and deliver request to it.

The feature is tested with the memcached, file backend of tooz.
Other backends are not fully tested but may work as well, please
refer to tooz documentation for driver compatibilities[1].

[1] https://docs.openstack.org/tooz/latest/user/compatibility.html

Story: 2001842
Task: 30376

Change-Id: I419176cd6d44d74c066db275ef008fe8bb6ef37a
2019-08-12 15:29:55 +08:00

443 lines
14 KiB
Python

# 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 os
import random
import re
import flask
from oslo_utils import strutils
from oslo_utils import uuidutils
import six
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__)
LOG = utils.getProcessingLogger(__name__)
MINIMUM_API_VERSION = (1, 0)
CURRENT_API_VERSION = (1, 15)
DEFAULT_API_VERSION = CURRENT_API_VERSION
_LOGGING_EXCLUDED_KEYS = ('logs',)
def _init_middleware():
"""Initialize WSGI middleware.
:returns: None
"""
if CONF.auth_strategy != 'noauth':
utils.add_auth_middleware(_app)
else:
LOG.warning('Starting unauthenticated, please check'
' configuration')
utils.add_cors_middleware(_app)
def get_app():
"""Get the flask instance."""
_init_middleware()
return _app
# TODO(kaifeng) Extract rpc related code into a rpcapi module
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
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:
if ver.lower() == 'latest':
requested = CURRENT_API_VERSION
else:
requested = tuple(int(x) for x in ver.split('.'))
except (ValueError, TypeError):
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)
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):
@six.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
@_app.before_first_request
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.')
@_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)
@_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
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
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
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 confif 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
@six.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
@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)
@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 not found.'), code=404)
return flask.jsonify(resources=generate_resource_data(resources))
@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
@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 '', 202
else:
node_info = node_cache.get_node(node_id)
return flask.json.jsonify(generate_introspection_status(node_info))
@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)
)
data = {
'introspection': [generate_introspection_status(node)
for node in nodes]
}
return flask.json.jsonify(data)
@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 '', 202
@api('/v1/introspection/<node_id>/data', rule="introspection:data",
methods=['GET'])
def api_introspection_data(node_id):
try:
if not uuidutils.is_uuid_like(node_id):
node = ir_utils.get_node(node_id, fields=['uuid'])
node_id = node.uuid
res = process.get_introspection_data(node_id)
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)
@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.uuid
client = get_client_compat()
client.call({}, 'do_reapply', node_uuid=node_id, data=data)
return '', 202
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
@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 '', 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'))
response_code = (200 if _get_version() < (1, 6) else 201)
return flask.make_response(
flask.jsonify(rule_repr(rule, short=False)), response_code)
@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 '', 204
@_app.errorhandler(404)
def handle_404(error):
return error_response(error, code=404)