diff --git a/ironic/common/json_rpc/__init__.py b/ironic/common/json_rpc/__init__.py deleted file mode 100644 index ad58e3bc6b..0000000000 --- a/ironic/common/json_rpc/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# 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. - -from oslo_config import cfg - - -CONF = cfg.CONF - - -def auth_strategy(): - return CONF.json_rpc.auth_strategy or CONF.auth_strategy diff --git a/ironic/common/json_rpc/client.py b/ironic/common/json_rpc/client.py deleted file mode 100644 index 3fcc06d99a..0000000000 --- a/ironic/common/json_rpc/client.py +++ /dev/null @@ -1,207 +0,0 @@ -# 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. - -"""A simple JSON RPC client. - -This client is compatible with any JSON RPC 2.0 implementation, including ours. -""" - -from oslo_config import cfg -from oslo_log import log -from oslo_utils import importutils -from oslo_utils import netutils -from oslo_utils import strutils -from oslo_utils import uuidutils - -from ironic.common import exception -from ironic.common.i18n import _ -from ironic.common import json_rpc -from ironic.common import keystone - - -CONF = cfg.CONF -LOG = log.getLogger(__name__) -_SESSION = None - - -def _get_session(): - global _SESSION - - if _SESSION is None: - kwargs = {} - auth_strategy = json_rpc.auth_strategy() - if auth_strategy != 'keystone': - auth_type = 'none' if auth_strategy == 'noauth' else auth_strategy - CONF.set_default('auth_type', auth_type, group='json_rpc') - - # Deprecated, remove in W - if auth_strategy == 'http_basic': - if CONF.json_rpc.http_basic_username: - kwargs['username'] = CONF.json_rpc.http_basic_username - if CONF.json_rpc.http_basic_password: - kwargs['password'] = CONF.json_rpc.http_basic_password - - auth = keystone.get_auth('json_rpc', **kwargs) - - session = keystone.get_session('json_rpc', auth=auth) - headers = { - 'Content-Type': 'application/json' - } - - # Adds options like connect_retries - _SESSION = keystone.get_adapter('json_rpc', session=session, - additional_headers=headers) - - return _SESSION - - -class Client(object): - """JSON RPC client with ironic exception handling.""" - - def __init__(self, serializer, version_cap=None): - self.serializer = serializer - self.version_cap = version_cap - - def can_send_version(self, version): - return _can_send_version(version, self.version_cap) - - def prepare(self, topic, version=None): - host = topic.split('.', 1)[1] - return _CallContext(host, self.serializer, version=version, - version_cap=self.version_cap) - - -class _CallContext(object): - """Wrapper object for compatibility with oslo.messaging API.""" - - def __init__(self, host, serializer, version=None, version_cap=None): - self.host = host - self.serializer = serializer - self.version = version - self.version_cap = version_cap - - def _handle_error(self, error): - if not error: - return - - message = error['message'] - try: - cls = error['data']['class'] - except KeyError: - LOG.error("Unexpected error from RPC: %s", error) - raise exception.IronicException( - _("Unexpected error raised by RPC")) - else: - if not cls.startswith('ironic.common.exception.'): - # NOTE(dtantsur): protect against arbitrary code execution - LOG.error("Unexpected error from RPC: %s", error) - raise exception.IronicException( - _("Unexpected error raised by RPC")) - raise importutils.import_object(cls, message, - code=error.get('code', 500)) - - def call(self, context, method, version=None, **kwargs): - """Call conductor RPC. - - Versioned objects are automatically serialized and deserialized. - - :param context: Security context. - :param method: Method name. - :param version: RPC API version to use. - :param kwargs: Keyword arguments to pass. - :return: RPC result (if any). - """ - return self._request(context, method, cast=False, version=version, - **kwargs) - - def cast(self, context, method, version=None, **kwargs): - """Call conductor RPC asynchronously. - - Versioned objects are automatically serialized and deserialized. - - :param context: Security context. - :param method: Method name. - :param version: RPC API version to use. - :param kwargs: Keyword arguments to pass. - :return: None - """ - return self._request(context, method, cast=True, version=version, - **kwargs) - - def _request(self, context, method, cast=False, version=None, **kwargs): - """Call conductor RPC. - - Versioned objects are automatically serialized and deserialized. - - :param context: Security context. - :param method: Method name. - :param cast: If true, use a JSON RPC notification. - :param version: RPC API version to use. - :param kwargs: Keyword arguments to pass. - :return: RPC result (if any). - """ - params = {key: self.serializer.serialize_entity(context, value) - for key, value in kwargs.items()} - params['context'] = context.to_dict() - - if version is None: - version = self.version - if version is not None: - _check_version(version, self.version_cap) - params['rpc.version'] = version - - body = { - "jsonrpc": "2.0", - "method": method, - "params": params, - } - if not cast: - body['id'] = context.request_id or uuidutils.generate_uuid() - - LOG.debug("RPC %s with %s", method, strutils.mask_dict_password(body)) - scheme = 'http' - if CONF.json_rpc.use_ssl: - scheme = 'https' - url = '%s://%s:%d' % (scheme, - netutils.escape_ipv6(self.host), - CONF.json_rpc.port) - result = _get_session().post(url, json=body) - LOG.debug('RPC %s returned %s', method, - strutils.mask_password(result.text or '')) - - if not cast: - result = result.json() - self._handle_error(result.get('error')) - result = self.serializer.deserialize_entity(context, - result['result']) - return result - - -def _can_send_version(requested, version_cap): - if requested is None or version_cap is None: - return True - - requested_parts = [int(item) for item in requested.split('.', 1)] - version_cap_parts = [int(item) for item in version_cap.split('.', 1)] - - if requested_parts[0] != version_cap_parts[0]: - return False # major version mismatch - else: - return requested_parts[1] <= version_cap_parts[1] - - -def _check_version(requested, version_cap): - if not _can_send_version(requested, version_cap): - raise RuntimeError(_("Cannot send RPC request: requested version " - "%(requested)s, maximum allowed version is " - "%(version_cap)s") % {'requested': requested, - 'version_cap': version_cap}) diff --git a/ironic/common/json_rpc/server.py b/ironic/common/json_rpc/server.py deleted file mode 100644 index 2fdab0c4fc..0000000000 --- a/ironic/common/json_rpc/server.py +++ /dev/null @@ -1,293 +0,0 @@ -# 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() diff --git a/ironic/common/rpc_service.py b/ironic/common/rpc_service.py index a385822506..edf14e9be7 100644 --- a/ironic/common/rpc_service.py +++ b/ironic/common/rpc_service.py @@ -16,6 +16,7 @@ import signal +from ironic_lib.json_rpc import server as json_rpc from oslo_config import cfg from oslo_log import log import oslo_messaging as messaging @@ -23,7 +24,6 @@ from oslo_service import service from oslo_utils import importutils from ironic.common import context -from ironic.common.json_rpc import server as json_rpc from ironic.common import rpc from ironic.objects import base as objects_base @@ -51,8 +51,8 @@ class RPCService(service.Service): # Perform preparatory actions before starting the RPC listener self.manager.prepare_host() if CONF.rpc_transport == 'json-rpc': - self.rpcserver = json_rpc.WSGIService(self.manager, - serializer) + self.rpcserver = json_rpc.WSGIService( + self.manager, serializer, context.RequestContext.from_dict) else: target = messaging.Target(topic=self.topic, server=self.host) endpoints = [self.manager] diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index e41cb77919..e8fa084ac9 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -20,12 +20,12 @@ Client side of the conductor RPC API. import random +from ironic_lib.json_rpc import client as json_rpc import oslo_messaging as messaging from ironic.common import exception from ironic.common import hash_ring from ironic.common.i18n import _ -from ironic.common.json_rpc import client as json_rpc from ironic.common import release_mappings as versions from ironic.common import rpc from ironic.conductor import manager diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index 9243cbbe3c..1503fdd210 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -35,7 +35,6 @@ from ironic.conf import inspector from ironic.conf import ipmi from ironic.conf import irmc from ironic.conf import iscsi -from ironic.conf import json_rpc from ironic.conf import metrics from ironic.conf import metrics_statsd from ironic.conf import neutron @@ -69,7 +68,6 @@ inspector.register_opts(CONF) ipmi.register_opts(CONF) irmc.register_opts(CONF) iscsi.register_opts(CONF) -json_rpc.register_opts(CONF) metrics.register_opts(CONF) metrics_statsd.register_opts(CONF) neutron.register_opts(CONF) diff --git a/ironic/conf/json_rpc.py b/ironic/conf/json_rpc.py deleted file mode 100644 index 3fdff21f45..0000000000 --- a/ironic/conf/json_rpc.py +++ /dev/null @@ -1,61 +0,0 @@ -# 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. - -from oslo_config import cfg - -from ironic.common.i18n import _ -from ironic.conf import auth - -opts = [ - cfg.StrOpt('auth_strategy', - choices=[('noauth', _('no authentication')), - ('keystone', _('use the Identity service for ' - 'authentication')), - ('http_basic', _('HTTP basic authentication'))], - help=_('Authentication strategy used by JSON RPC. Defaults to ' - 'the global auth_strategy setting.')), - cfg.StrOpt('http_basic_auth_user_file', - default='/etc/ironic/htpasswd-json-rpc', - help=_('Path to Apache format user authentication file used ' - 'when auth_strategy=http_basic')), - cfg.HostAddressOpt('host_ip', - default='::', - help=_('The IP address or hostname on which JSON RPC ' - 'will listen.')), - cfg.PortOpt('port', - default=8089, - help=_('The port to use for JSON RPC')), - cfg.BoolOpt('use_ssl', - default=False, - help=_('Whether to use TLS for JSON RPC')), - cfg.StrOpt('http_basic_username', - deprecated_for_removal=True, - deprecated_reason=_("Use username instead"), - help=_("Name of the user to use for HTTP Basic authentication " - "client requests.")), - cfg.StrOpt('http_basic_password', - deprecated_for_removal=True, - deprecated_reason=_("Use password instead"), - secret=True, - help=_("Password to use for HTTP Basic authentication " - "client requests.")), -] - - -def register_opts(conf): - conf.register_opts(opts, group='json_rpc') - auth.register_auth_opts(conf, 'json_rpc') - conf.set_default('timeout', 120, group='json_rpc') - - -def list_opts(): - return opts + auth.add_auth_opts([]) diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index 464469d193..1cde057cb2 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -51,7 +51,6 @@ _opts = [ ('ipmi', ironic.conf.ipmi.opts), ('irmc', ironic.conf.irmc.opts), ('iscsi', ironic.conf.iscsi.opts), - ('json_rpc', ironic.conf.json_rpc.list_opts()), ('metrics', ironic.conf.metrics.opts), ('metrics_statsd', ironic.conf.metrics_statsd.opts), ('neutron', ironic.conf.neutron.list_opts()), diff --git a/ironic/tests/unit/common/test_json_rpc.py b/ironic/tests/unit/common/test_json_rpc.py deleted file mode 100644 index fb7e7eca04..0000000000 --- a/ironic/tests/unit/common/test_json_rpc.py +++ /dev/null @@ -1,714 +0,0 @@ -# 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 tempfile -from unittest import mock - -import fixtures -import oslo_messaging -import webob - -from ironic.common import context as ir_ctx -from ironic.common import exception -from ironic.common.json_rpc import client -from ironic.common.json_rpc import server -from ironic import objects -from ironic.objects import base as objects_base -from ironic.tests import base as test_base -from ironic.tests.unit.db import utils as db_utils -from ironic.tests.unit.objects import utils as obj_utils - - -class FakeManager(object): - - def success(self, context, x, y=0): - assert isinstance(context, ir_ctx.RequestContext) - assert context.user_name == 'admin' - return x - y - - def with_node(self, context, node): - assert isinstance(context, ir_ctx.RequestContext) - assert isinstance(node, objects.Node) - node.extra['answer'] = 42 - return node - - def no_result(self, context): - assert isinstance(context, ir_ctx.RequestContext) - return None - - def no_context(self): - return 42 - - def fail(self, context, message): - assert isinstance(context, ir_ctx.RequestContext) - raise exception.IronicException(message) - - @oslo_messaging.expected_exceptions(exception.Invalid) - def expected(self, context, message): - assert isinstance(context, ir_ctx.RequestContext) - raise exception.Invalid(message) - - def crash(self, context): - raise RuntimeError('boom') - - def init_host(self, context): - assert False, "This should not be exposed" - - def _private(self, context): - assert False, "This should not be exposed" - - # This should not be exposed either - value = 42 - - -class TestService(test_base.TestCase): - - def setUp(self): - super(TestService, self).setUp() - self.config(auth_strategy='noauth', group='json_rpc') - self.server_mock = self.useFixture(fixtures.MockPatch( - 'oslo_service.wsgi.Server', autospec=True)).mock - - self.serializer = objects_base.IronicObjectSerializer(is_server=True) - self.service = server.WSGIService(FakeManager(), self.serializer) - self.app = self.service._application - self.ctx = {'user_name': 'admin'} - - def _request(self, name=None, params=None, expected_error=None, - request_id='abcd', **kwargs): - body = { - 'jsonrpc': '2.0', - } - if request_id is not None: - body['id'] = request_id - if name is not None: - body['method'] = name - if params is not None: - body['params'] = params - if 'json_body' not in kwargs: - kwargs['json_body'] = body - kwargs.setdefault('method', 'POST') - kwargs.setdefault('headers', {'Content-Type': 'application/json'}) - - request = webob.Request.blank("/", **kwargs) - response = request.get_response(self.app) - self.assertEqual(response.status_code, - expected_error or (200 if request_id else 204)) - if request_id is not None: - if expected_error: - self.assertEqual(expected_error, - response.json_body['error']['code']) - else: - return response.json_body - else: - return response.text - - def _check(self, body, result=None, error=None, request_id='abcd'): - self.assertEqual('2.0', body.pop('jsonrpc')) - self.assertEqual(request_id, body.pop('id')) - if error is not None: - self.assertEqual({'error': error}, body) - else: - self.assertEqual({'result': result}, body) - - def _setup_http_basic(self): - with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: - f.write('myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' - 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') - self.addCleanup(os.remove, f.name) - self.config(http_basic_auth_user_file=f.name, group='json_rpc') - self.config(auth_strategy='http_basic', group='json_rpc') - # self.config(http_basic_username='myUser', group='json_rpc') - # self.config(http_basic_password='myPassword', group='json_rpc') - self.service = server.WSGIService(FakeManager(), self.serializer) - self.app = self.server_mock.call_args[0][2] - - def test_http_basic_not_authenticated(self): - self._setup_http_basic() - self._request('success', {'context': self.ctx, 'x': 42}, - request_id=None, expected_error=401) - - def test_http_basic(self): - self._setup_http_basic() - headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Basic bXlOYW1lOm15UGFzc3dvcmQ=' - } - body = self._request('success', {'context': self.ctx, 'x': 42}, - headers=headers) - self._check(body, result=42) - - def test_success(self): - body = self._request('success', {'context': self.ctx, 'x': 42}) - self._check(body, result=42) - - def test_success_no_result(self): - body = self._request('no_result', {'context': self.ctx}) - self._check(body, result=None) - - def test_notification(self): - body = self._request('no_result', {'context': self.ctx}, - request_id=None) - self.assertEqual('', body) - - def test_no_context(self): - body = self._request('no_context') - self._check(body, result=42) - - def test_serialize_objects(self): - node = obj_utils.get_test_node(self.context) - node = self.serializer.serialize_entity(self.context, node) - body = self._request('with_node', {'context': self.ctx, 'node': node}) - self.assertNotIn('error', body) - self.assertIsInstance(body['result'], dict) - node = self.serializer.deserialize_entity(self.context, body['result']) - self.assertEqual({'answer': 42}, node.extra) - - def test_non_json_body(self): - for body in (b'', b'???', b"\xc3\x28"): - request = webob.Request.blank("/", method='POST', body=body) - response = request.get_response(self.app) - self._check( - response.json_body, - error={ - 'message': server.ParseError._msg_fmt, - 'code': -32700, - }, - request_id=None) - - def test_invalid_requests(self): - bodies = [ - # Invalid requests with request ID. - {'method': 'no_result', 'id': 'abcd', - 'params': {'context': self.ctx}}, - {'jsonrpc': '2.0', 'id': 'abcd', 'params': {'context': self.ctx}}, - # These do not count as notifications, since they're malformed. - {'method': 'no_result', 'params': {'context': self.ctx}}, - {'jsonrpc': '2.0', 'params': {'context': self.ctx}}, - 42, - # We do not implement batched requests. - [], - [{'jsonrpc': '2.0', 'method': 'no_result', - 'params': {'context': self.ctx}}], - ] - for body in bodies: - body = self._request(json_body=body) - self._check( - body, - error={ - 'message': server.InvalidRequest._msg_fmt, - 'code': -32600, - }, - request_id=body.get('id')) - - def test_malformed_context(self): - body = self._request(json_body={'jsonrpc': '2.0', 'id': 'abcd', - 'method': 'no_result', - 'params': {'context': 42}}) - self._check( - body, - error={ - 'message': 'Context must be a dictionary, if provided', - 'code': -32602, - }) - - def test_expected_failure(self): - body = self._request('fail', {'context': self.ctx, - 'message': 'some error'}) - self._check(body, - error={ - 'message': 'some error', - 'code': 500, - 'data': { - 'class': 'ironic_lib.exception.IronicException' - } - }) - - def test_expected_failure_oslo(self): - # Check that exceptions wrapped by oslo's expected_exceptions get - # unwrapped correctly. - body = self._request('expected', {'context': self.ctx, - 'message': 'some error'}) - self._check(body, - error={ - 'message': 'some error', - 'code': 400, - 'data': { - 'class': 'ironic.common.exception.Invalid' - } - }) - - @mock.patch.object(server.LOG, 'exception', autospec=True) - def test_unexpected_failure(self, mock_log): - body = self._request('crash', {'context': self.ctx}) - self._check(body, - error={ - 'message': 'boom', - 'code': 500, - }) - self.assertTrue(mock_log.called) - - def test_method_not_found(self): - body = self._request('banana', {'context': self.ctx}) - self._check(body, - error={ - 'message': 'Method banana was not found', - 'code': -32601, - }) - - def test_no_deny_methods(self): - for name in ('__init__', '_private', 'init_host', 'value'): - body = self._request(name, {'context': self.ctx}) - self._check(body, - error={ - 'message': 'Method %s was not found' % name, - 'code': -32601, - }) - - def test_missing_argument(self): - body = self._request('success', {'context': self.ctx}) - # The exact error message depends on the Python version - self.assertEqual(-32602, body['error']['code']) - self.assertNotIn('result', body) - - def test_method_not_post(self): - self._request('success', {'context': self.ctx, 'x': 42}, - method='GET', expected_error=405) - - def test_authenticated(self): - self.config(auth_strategy='keystone', group='json_rpc') - self.service = server.WSGIService(FakeManager(), self.serializer) - self.app = self.server_mock.call_args[0][2] - self._request('success', {'context': self.ctx, 'x': 42}, - expected_error=401) - - def test_authenticated_no_admin_role(self): - self.config(auth_strategy='keystone', group='json_rpc') - self._request('success', {'context': self.ctx, 'x': 42}, - expected_error=403) - - @mock.patch.object(server.LOG, 'debug', autospec=True) - def test_mask_secrets(self, mock_log): - node = obj_utils.get_test_node( - self.context, driver_info=db_utils.get_test_ipmi_info()) - node = self.serializer.serialize_entity(self.context, node) - body = self._request('with_node', {'context': self.ctx, 'node': node}) - node = self.serializer.deserialize_entity(self.context, body['result']) - logged_params = mock_log.call_args_list[0][0][2] - logged_node = logged_params['node']['ironic_object.data'] - self.assertEqual('***', logged_node['driver_info']['ipmi_password']) - logged_resp = mock_log.call_args_list[1][0][2] - logged_node = logged_resp['ironic_object.data'] - self.assertEqual('***', logged_node['driver_info']['ipmi_password']) - # The result is not affected, only logging - self.assertEqual(db_utils.get_test_ipmi_info(), node.driver_info) - - -@mock.patch.object(client, '_get_session', autospec=True) -class TestClient(test_base.TestCase): - - def setUp(self): - super(TestClient, self).setUp() - self.serializer = objects_base.IronicObjectSerializer(is_server=True) - self.client = client.Client(self.serializer) - self.ctx_json = self.context.to_dict() - - def test_can_send_version(self, mock_session): - self.assertTrue(self.client.can_send_version('1.42')) - self.client = client.Client(self.serializer, version_cap='1.42') - self.assertTrue(self.client.can_send_version('1.42')) - self.assertTrue(self.client.can_send_version('1.0')) - self.assertFalse(self.client.can_send_version('1.99')) - self.assertFalse(self.client.can_send_version('2.0')) - - def test_call_success(self, mock_session): - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': 42 - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - result = cctx.call(self.context, 'do_something', answer=42) - self.assertEqual(42, result) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_call_ipv4_success(self, mock_session): - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': 42 - } - cctx = self.client.prepare('foo.192.0.2.1') - self.assertEqual('192.0.2.1', cctx.host) - result = cctx.call(self.context, 'do_something', answer=42) - self.assertEqual(42, result) - mock_session.return_value.post.assert_called_once_with( - 'http://192.0.2.1:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_call_ipv6_success(self, mock_session): - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': 42 - } - cctx = self.client.prepare('foo.2001:db8::1') - self.assertEqual('2001:db8::1', cctx.host) - result = cctx.call(self.context, 'do_something', answer=42) - self.assertEqual(42, result) - mock_session.return_value.post.assert_called_once_with( - 'http://[2001:db8::1]:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_call_success_with_version(self, mock_session): - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': 42 - } - cctx = self.client.prepare('foo.example.com', version='1.42') - self.assertEqual('example.com', cctx.host) - result = cctx.call(self.context, 'do_something', answer=42) - self.assertEqual(42, result) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json, - 'rpc.version': '1.42'}, - 'id': self.context.request_id}) - - def test_call_success_with_version_and_cap(self, mock_session): - self.client = client.Client(self.serializer, version_cap='1.99') - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': 42 - } - cctx = self.client.prepare('foo.example.com', version='1.42') - self.assertEqual('example.com', cctx.host) - result = cctx.call(self.context, 'do_something', answer=42) - self.assertEqual(42, result) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json, - 'rpc.version': '1.42'}, - 'id': self.context.request_id}) - - def test_call_with_ssl(self, mock_session): - self.config(use_ssl=True, group='json_rpc') - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': 42 - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - result = cctx.call(self.context, 'do_something', answer=42) - self.assertEqual(42, result) - mock_session.return_value.post.assert_called_once_with( - 'https://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_cast_success(self, mock_session): - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - result = cctx.cast(self.context, 'do_something', answer=42) - self.assertIsNone(result) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}}) - - def test_cast_success_with_version(self, mock_session): - cctx = self.client.prepare('foo.example.com', version='1.42') - self.assertEqual('example.com', cctx.host) - result = cctx.cast(self.context, 'do_something', answer=42) - self.assertIsNone(result) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json, - 'rpc.version': '1.42'}}) - - def test_call_serialization(self, mock_session): - node = obj_utils.get_test_node(self.context) - node_json = self.serializer.serialize_entity(self.context, node) - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'result': node_json - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - result = cctx.call(self.context, 'do_something', node=node) - self.assertIsInstance(result, objects.Node) - self.assertEqual(result.uuid, node.uuid) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'node': node_json, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_call_failure(self, mock_session): - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'error': { - 'code': 418, - 'message': 'I am a teapot', - 'data': { - 'class': 'ironic.common.exception.Invalid' - } - } - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - # Make sure that the class is restored correctly for expected errors. - exc = self.assertRaises(exception.Invalid, - cctx.call, - self.context, 'do_something', answer=42) - # Code from the body has priority over one in the class. - self.assertEqual(418, exc.code) - self.assertIn('I am a teapot', str(exc)) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_call_unexpected_failure(self, mock_session): - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'error': { - 'code': 500, - 'message': 'AttributeError', - } - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - exc = self.assertRaises(exception.IronicException, - cctx.call, - self.context, 'do_something', answer=42) - self.assertEqual(500, exc.code) - self.assertIn('Unexpected error', str(exc)) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_call_failure_with_foreign_class(self, mock_session): - # This should not happen, but provide an additional safeguard - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'error': { - 'code': 500, - 'message': 'AttributeError', - 'data': { - 'class': 'AttributeError' - } - } - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - exc = self.assertRaises(exception.IronicException, - cctx.call, - self.context, 'do_something', answer=42) - self.assertEqual(500, exc.code) - self.assertIn('Unexpected error', str(exc)) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}, - 'id': self.context.request_id}) - - def test_cast_failure(self, mock_session): - # Cast cannot return normal failures, but make sure we ignore them even - # if server sends something in violation of the protocol (or because - # it's a low-level error like HTTP Forbidden). - response = mock_session.return_value.post.return_value - response.json.return_value = { - 'jsonrpc': '2.0', - 'error': { - 'code': 418, - 'message': 'I am a teapot', - 'data': { - 'class': 'ironic.common.exception.IronicException' - } - } - } - cctx = self.client.prepare('foo.example.com') - self.assertEqual('example.com', cctx.host) - result = cctx.cast(self.context, 'do_something', answer=42) - self.assertIsNone(result) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'answer': 42, 'context': self.ctx_json}}) - - def test_call_failure_with_version_and_cap(self, mock_session): - self.client = client.Client(self.serializer, version_cap='1.42') - cctx = self.client.prepare('foo.example.com', version='1.99') - self.assertRaisesRegex(RuntimeError, - "requested version 1.99, maximum allowed " - "version is 1.42", - cctx.call, self.context, 'do_something', - answer=42) - self.assertFalse(mock_session.return_value.post.called) - - @mock.patch.object(client.LOG, 'debug', autospec=True) - def test_mask_secrets(self, mock_log, mock_session): - request = { - 'redfish_username': 'admin', - 'redfish_password': 'passw0rd' - } - body = """{ - "jsonrpc": "2.0", - "result": { - "driver_info": { - "ipmi_username": "admin", - "ipmi_password": "passw0rd" - } - } - }""" - response = mock_session.return_value.post.return_value - response.text = body - cctx = self.client.prepare('foo.example.com') - cctx.cast(self.context, 'do_something', node=request) - mock_session.return_value.post.assert_called_once_with( - 'http://example.com:8089', - json={'jsonrpc': '2.0', - 'method': 'do_something', - 'params': {'node': request, 'context': self.ctx_json}}) - self.assertEqual(2, mock_log.call_count) - node = mock_log.call_args_list[0][0][2]['params']['node'] - self.assertEqual(node, {'redfish_username': 'admin', - 'redfish_password': '***'}) - resp_text = mock_log.call_args_list[1][0][2] - self.assertEqual(body.replace('passw0rd', '***'), resp_text) - - -@mock.patch('ironic.common.json_rpc.client.keystone', autospec=True) -class TestSession(test_base.TestCase): - - def setUp(self): - super(TestSession, self).setUp() - client._SESSION = None - - def test_noauth(self, mock_keystone): - self.config(auth_strategy='noauth', group='json_rpc') - session = client._get_session() - - mock_keystone.get_auth.assert_called_once_with('json_rpc') - auth = mock_keystone.get_auth.return_value - - mock_keystone.get_session.assert_called_once_with( - 'json_rpc', auth=auth) - - internal_session = mock_keystone.get_session.return_value - - mock_keystone.get_adapter.assert_called_once_with( - 'json_rpc', - session=internal_session, - additional_headers={ - 'Content-Type': 'application/json' - }) - self.assertEqual(mock_keystone.get_adapter.return_value, session) - - def test_keystone(self, mock_keystone): - self.config(auth_strategy='keystone', group='json_rpc') - session = client._get_session() - - mock_keystone.get_auth.assert_called_once_with('json_rpc') - auth = mock_keystone.get_auth.return_value - - mock_keystone.get_session.assert_called_once_with( - 'json_rpc', auth=auth) - - internal_session = mock_keystone.get_session.return_value - - mock_keystone.get_adapter.assert_called_once_with( - 'json_rpc', - session=internal_session, - additional_headers={ - 'Content-Type': 'application/json' - }) - self.assertEqual(mock_keystone.get_adapter.return_value, session) - - def test_http_basic(self, mock_keystone): - self.config(auth_strategy='http_basic', group='json_rpc') - session = client._get_session() - - mock_keystone.get_auth.assert_called_once_with('json_rpc') - auth = mock_keystone.get_auth.return_value - mock_keystone.get_session.assert_called_once_with( - 'json_rpc', auth=auth) - - internal_session = mock_keystone.get_session.return_value - - mock_keystone.get_adapter.assert_called_once_with( - 'json_rpc', - session=internal_session, - additional_headers={ - 'Content-Type': 'application/json' - }) - self.assertEqual(mock_keystone.get_adapter.return_value, session) - - def test_http_basic_deprecated(self, mock_keystone): - self.config(auth_strategy='http_basic', group='json_rpc') - self.config(http_basic_username='myName', group='json_rpc') - self.config(http_basic_password='myPassword', group='json_rpc') - session = client._get_session() - - mock_keystone.get_auth.assert_called_once_with( - 'json_rpc', username='myName', password='myPassword') - auth = mock_keystone.get_auth.return_value - mock_keystone.get_session.assert_called_once_with( - 'json_rpc', auth=auth) - - internal_session = mock_keystone.get_session.return_value - - mock_keystone.get_adapter.assert_called_once_with( - 'json_rpc', - session=internal_session, - additional_headers={ - 'Content-Type': 'application/json' - }) - self.assertEqual(mock_keystone.get_adapter.return_value, session) diff --git a/requirements.txt b/requirements.txt index 192913b64e..ca59703ccb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ WebOb>=1.7.1 # MIT python-cinderclient!=4.0.0,>=3.3.0 # Apache-2.0 python-glanceclient>=2.8.0 # Apache-2.0 keystoneauth1>=4.2.0 # Apache-2.0 -ironic-lib>=4.3.0 # Apache-2.0 +ironic-lib>=4.6.1 # Apache-2.0 python-swiftclient>=3.2.0 # Apache-2.0 pytz>=2013.6 # MIT stevedore>=1.20.0 # Apache-2.0 diff --git a/tools/config/ironic-config-generator.conf b/tools/config/ironic-config-generator.conf index a14a0ec325..5c01f82dda 100644 --- a/tools/config/ironic-config-generator.conf +++ b/tools/config/ironic-config-generator.conf @@ -5,6 +5,7 @@ namespace = ironic namespace = ironic_lib.disk_utils namespace = ironic_lib.disk_partitioner namespace = ironic_lib.exception +namespace = ironic_lib.json_rpc namespace = ironic_lib.mdns namespace = ironic_lib.metrics namespace = ironic_lib.metrics_statsd