9c6c7c0a57
In https://review.openstack.org/#/c/637673, the get introspection interface is narrowed down to only accept node uuid, which previously accepts both uuid and name. But the name to uuid conversion is missing in the reapply api, which causes feature regression, and this is the fix :( Story: 1726713 Task: 11373 Change-Id: I6912853deab77f1365f665ca1e52c13063d2cdf1
372 lines
12 KiB
Python
372 lines
12 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 re
|
|
|
|
import flask
|
|
from oslo_utils import strutils
|
|
from oslo_utils import uuidutils
|
|
import six
|
|
import werkzeug
|
|
|
|
from ironic_inspector import api_tools
|
|
from ironic_inspector.common import context
|
|
from ironic_inspector.common.i18n import _
|
|
from ironic_inspector.common import ironic as ir_utils
|
|
from ironic_inspector.common import rpc
|
|
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, 14)
|
|
DEFAULT_API_VERSION = CURRENT_API_VERSION
|
|
_LOGGING_EXCLUDED_KEYS = ('logs',)
|
|
|
|
|
|
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 werkzeug.exceptions.HTTPException as exc:
|
|
return error_response(exc, exc.code or 400)
|
|
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_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)
|
|
|
|
return flask.jsonify(process.process(data))
|
|
|
|
|
|
# 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 = rpc.get_client()
|
|
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 = rpc.get_client()
|
|
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):
|
|
if flask.request.content_length:
|
|
return error_response(_('User data processing is not '
|
|
'supported yet'), code=400)
|
|
if not uuidutils.is_uuid_like(node_id):
|
|
node = ir_utils.get_node(node_id, fields=['uuid'])
|
|
node_id = node.uuid
|
|
client = rpc.get_client()
|
|
client.call({}, 'do_reapply', node_uuid=node_id)
|
|
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)
|