You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
373 lines
12 KiB
373 lines
12 KiB
# 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 |
|
from ironic_inspector.common import swift |
|
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: |
|
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'), 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 convertion, |
|
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): |
|
if CONF.processing.store_data == 'swift': |
|
if not uuidutils.is_uuid_like(node_id): |
|
node = ir_utils.get_node(node_id, fields=['uuid']) |
|
node_id = node.uuid |
|
res = swift.get_introspection_data(node_id) |
|
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) |
|
|
|
|
|
@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 CONF.processing.store_data == 'swift': |
|
client = rpc.get_client() |
|
client.call({}, 'do_reapply', node_id=node_id) |
|
return '', 202 |
|
else: |
|
return error_response(_('Inspector is not configured to store' |
|
' data. Set the [processing] ' |
|
'store_data configuration option to ' |
|
'change this.'), code=400) |
|
|
|
|
|
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)
|
|
|