294 lines
9.9 KiB
Python
294 lines
9.9 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.
|
|
|
|
"""Implementation of JSON RPC for communication between API and conductors.
|
|
|
|
This module implementa a subset of JSON RPC 2.0 as defined in
|
|
https://www.jsonrpc.org/specification. Main differences:
|
|
* No support for batched requests.
|
|
* No support for positional arguments passing.
|
|
* No JSON RPC 1.0 fallback.
|
|
"""
|
|
|
|
import json
|
|
|
|
from ironic_lib import auth_basic
|
|
from keystonemiddleware import auth_token
|
|
from oslo_config import cfg
|
|
from oslo_log import log
|
|
import oslo_messaging
|
|
from oslo_service import service
|
|
from oslo_service import wsgi
|
|
from oslo_utils import strutils
|
|
import webob
|
|
|
|
from ironic.common import context as ir_context
|
|
from ironic.common import exception
|
|
from ironic.common.i18n import _
|
|
from ironic.common import json_rpc
|
|
|
|
|
|
CONF = cfg.CONF
|
|
LOG = log.getLogger(__name__)
|
|
_DENY_LIST = {'init_host', 'del_host', 'target', 'iter_nodes'}
|
|
|
|
|
|
def _build_method_map(manager):
|
|
"""Build mapping from method names to their bodies.
|
|
|
|
:param manager: A conductor manager.
|
|
:return: dict with mapping
|
|
"""
|
|
result = {}
|
|
for method in dir(manager):
|
|
if method.startswith('_') or method in _DENY_LIST:
|
|
continue
|
|
func = getattr(manager, method)
|
|
if not callable(func):
|
|
continue
|
|
LOG.debug('Adding RPC method %s', method)
|
|
result[method] = func
|
|
return result
|
|
|
|
|
|
class JsonRpcError(exception.IronicException):
|
|
pass
|
|
|
|
|
|
class ParseError(JsonRpcError):
|
|
code = -32700
|
|
_msg_fmt = _("Invalid JSON received by RPC server")
|
|
|
|
|
|
class InvalidRequest(JsonRpcError):
|
|
code = -32600
|
|
_msg_fmt = _("Invalid request object received by RPC server")
|
|
|
|
|
|
class MethodNotFound(JsonRpcError):
|
|
code = -32601
|
|
_msg_fmt = _("Method %(name)s was not found")
|
|
|
|
|
|
class InvalidParams(JsonRpcError):
|
|
code = -32602
|
|
_msg_fmt = _("Params %(params)s are invalid for %(method)s: %(error)s")
|
|
|
|
|
|
class WSGIService(service.Service):
|
|
"""Provides ability to launch JSON RPC as a WSGI application."""
|
|
|
|
def __init__(self, manager, serializer):
|
|
self.manager = manager
|
|
self.serializer = serializer
|
|
self._method_map = _build_method_map(manager)
|
|
auth_strategy = json_rpc.auth_strategy()
|
|
if auth_strategy == 'keystone':
|
|
conf = dict(CONF.keystone_authtoken)
|
|
app = auth_token.AuthProtocol(self._application, conf)
|
|
elif auth_strategy == 'http_basic':
|
|
app = auth_basic.BasicAuthMiddleware(
|
|
self._application,
|
|
cfg.CONF.json_rpc.http_basic_auth_user_file)
|
|
else:
|
|
app = self._application
|
|
self.server = wsgi.Server(CONF, 'ironic-json-rpc', app,
|
|
host=CONF.json_rpc.host_ip,
|
|
port=CONF.json_rpc.port,
|
|
use_ssl=CONF.json_rpc.use_ssl)
|
|
|
|
def _application(self, environment, start_response):
|
|
"""WSGI application for conductor JSON RPC."""
|
|
request = webob.Request(environment)
|
|
if request.method != 'POST':
|
|
body = {'error': {'code': 405,
|
|
'message': _('Only POST method can be used')}}
|
|
return webob.Response(status_code=405, json_body=body)(
|
|
environment, start_response)
|
|
|
|
if json_rpc.auth_strategy() == 'keystone':
|
|
roles = (request.headers.get('X-Roles') or '').split(',')
|
|
if 'admin' not in roles:
|
|
LOG.debug('Roles %s do not contain "admin", rejecting '
|
|
'request', roles)
|
|
body = {'error': {'code': 403, 'message': _('Forbidden')}}
|
|
return webob.Response(status_code=403, json_body=body)(
|
|
environment, start_response)
|
|
|
|
result = self._call(request)
|
|
if result is not None:
|
|
response = webob.Response(content_type='application/json',
|
|
charset='UTF-8',
|
|
json_body=result)
|
|
else:
|
|
response = webob.Response(status_code=204)
|
|
return response(environment, start_response)
|
|
|
|
def _handle_error(self, exc, request_id=None):
|
|
"""Generate a JSON RPC 2.0 error body.
|
|
|
|
:param exc: Exception object.
|
|
:param request_id: ID of the request (if any).
|
|
:return: dict with response body
|
|
"""
|
|
if isinstance(exc, oslo_messaging.ExpectedException):
|
|
exc = exc.exc_info[1]
|
|
|
|
expected = isinstance(exc, exception.IronicException)
|
|
cls = exc.__class__
|
|
if expected:
|
|
LOG.debug('RPC error %s: %s', cls.__name__, exc)
|
|
else:
|
|
LOG.exception('Unexpected RPC exception %s', cls.__name__)
|
|
|
|
response = {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"error": {
|
|
"code": getattr(exc, 'code', 500),
|
|
"message": str(exc),
|
|
}
|
|
}
|
|
if expected and not isinstance(exc, JsonRpcError):
|
|
# Allow de-serializing the correct class for expected errors.
|
|
response['error']['data'] = {
|
|
'class': '%s.%s' % (cls.__module__, cls.__name__)
|
|
}
|
|
return response
|
|
|
|
def _call(self, request):
|
|
"""Process a JSON RPC request.
|
|
|
|
:param request: ``webob.Request`` object.
|
|
:return: dict with response body.
|
|
"""
|
|
request_id = None
|
|
try:
|
|
try:
|
|
body = json.loads(request.text)
|
|
except ValueError:
|
|
LOG.error('Cannot parse JSON RPC request as JSON')
|
|
raise ParseError()
|
|
|
|
if not isinstance(body, dict):
|
|
LOG.error('JSON RPC request %s is not an object (batched '
|
|
'requests are not supported)', body)
|
|
raise InvalidRequest()
|
|
|
|
request_id = body.get('id')
|
|
params = body.get('params', {})
|
|
|
|
if (body.get('jsonrpc') != '2.0'
|
|
or not body.get('method')
|
|
or not isinstance(params, dict)):
|
|
LOG.error('JSON RPC request %s is invalid', body)
|
|
raise InvalidRequest()
|
|
except Exception as exc:
|
|
# We do not treat malformed requests as notifications and return
|
|
# a response even when request_id is None. This seems in agreement
|
|
# with the examples in the specification.
|
|
return self._handle_error(exc, request_id)
|
|
|
|
try:
|
|
method = body['method']
|
|
try:
|
|
func = self._method_map[method]
|
|
except KeyError:
|
|
raise MethodNotFound(name=method)
|
|
|
|
result = self._handle_requests(func, method, params)
|
|
if request_id is not None:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"result": result,
|
|
"id": request_id
|
|
}
|
|
except Exception as exc:
|
|
result = self._handle_error(exc, request_id)
|
|
# We treat correctly formed requests without "id" as notifications
|
|
# and do not return any errors.
|
|
if request_id is not None:
|
|
return result
|
|
|
|
def _handle_requests(self, func, name, params):
|
|
"""Convert arguments and call a method.
|
|
|
|
:param func: Callable object.
|
|
:param name: RPC call name for logging.
|
|
:param params: Keyword arguments.
|
|
:return: call result as JSON.
|
|
"""
|
|
# TODO(dtantsur): server-side version check?
|
|
params.pop('rpc.version', None)
|
|
logged_params = strutils.mask_dict_password(params)
|
|
|
|
try:
|
|
context = params.pop('context')
|
|
except KeyError:
|
|
context = None
|
|
else:
|
|
# A valid context is required for deserialization
|
|
if not isinstance(context, dict):
|
|
raise InvalidParams(
|
|
_("Context must be a dictionary, if provided"))
|
|
|
|
context = ir_context.RequestContext.from_dict(context)
|
|
params = {key: self.serializer.deserialize_entity(context, value)
|
|
for key, value in params.items()}
|
|
params['context'] = context
|
|
|
|
LOG.debug('RPC %s with %s', name, logged_params)
|
|
try:
|
|
result = func(**params)
|
|
# FIXME(dtantsur): we could use the inspect module, but
|
|
# oslo_messaging.expected_exceptions messes up signatures.
|
|
except TypeError as exc:
|
|
raise InvalidParams(params=', '.join(params),
|
|
method=name, error=exc)
|
|
|
|
if context is not None:
|
|
# Currently it seems that we can serialize even with invalid
|
|
# context, but I'm not sure it's guaranteed to be the case.
|
|
result = self.serializer.serialize_entity(context, result)
|
|
LOG.debug('RPC %s returned %s', name,
|
|
strutils.mask_dict_password(result)
|
|
if isinstance(result, dict) else result)
|
|
return result
|
|
|
|
def start(self):
|
|
"""Start serving this service using loaded configuration.
|
|
|
|
:returns: None
|
|
"""
|
|
self.server.start()
|
|
|
|
def stop(self):
|
|
"""Stop serving this API.
|
|
|
|
:returns: None
|
|
"""
|
|
self.server.stop()
|
|
|
|
def wait(self):
|
|
"""Wait for the service to stop serving this API.
|
|
|
|
:returns: None
|
|
"""
|
|
self.server.wait()
|
|
|
|
def reset(self):
|
|
"""Reset server greenpool size to default.
|
|
|
|
:returns: None
|
|
"""
|
|
self.server.reset()
|