From 22a944bd31e546350b94d451dc3386ba75fdab0e Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Sat, 6 Feb 2021 12:34:24 +0100 Subject: [PATCH] Import json_rpc from ironic To be able to use the same JSON RPC implementation for ironic-inspector, it is now imported in ironic-lib. Some additional abstraction laters are added to avoid hard dependency on oslo.context and oslo.messaging. Change-Id: I0e790cebcd086d26bf27b909f73842c3f46bbd77 --- extra-requirements.txt | 1 + ironic_lib/json_rpc/__init__.py | 73 ++++ ironic_lib/json_rpc/client.py | 224 ++++++++++ ironic_lib/json_rpc/server.py | 323 ++++++++++++++ ironic_lib/tests/test_json_rpc.py | 697 ++++++++++++++++++++++++++++++ setup.cfg | 4 + test-requirements.txt | 5 + 7 files changed, 1327 insertions(+) create mode 100644 ironic_lib/json_rpc/__init__.py create mode 100644 ironic_lib/json_rpc/client.py create mode 100644 ironic_lib/json_rpc/server.py create mode 100644 ironic_lib/tests/test_json_rpc.py diff --git a/extra-requirements.txt b/extra-requirements.txt index 2e24ee30..5f54ba25 100644 --- a/extra-requirements.txt +++ b/extra-requirements.txt @@ -2,3 +2,4 @@ # in sync. It is used both in unit tests and when building docs. keystoneauth1>=4.2.0 # Apache-2.0 os-service-types>=1.2.0 # Apache-2.0 +oslo.service!=1.28.1,>=1.24.0 # Apache-2.0 diff --git a/ironic_lib/json_rpc/__init__.py b/ironic_lib/json_rpc/__init__.py new file mode 100644 index 00000000..60e23f66 --- /dev/null +++ b/ironic_lib/json_rpc/__init__.py @@ -0,0 +1,73 @@ +# 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_lib.common.i18n import _ +from ironic_lib import keystone + + +CONF = cfg.CONF + +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') + keystone.register_auth_opts(conf, 'json_rpc') + conf.set_default('timeout', 120, group='json_rpc') + + +register_opts(CONF) + + +def list_opts(): + return opts + keystone.add_auth_opts([]) + + +def auth_strategy(): + # NOTE(dtantsur): this expects [DEFAULT]auth_strategy to be provided by the + # service configuration. + return CONF.json_rpc.auth_strategy or CONF.auth_strategy diff --git a/ironic_lib/json_rpc/client.py b/ironic_lib/json_rpc/client.py new file mode 100644 index 00000000..bdb497ea --- /dev/null +++ b/ironic_lib/json_rpc/client.py @@ -0,0 +1,224 @@ +# 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_lib.common.i18n import _ +from ironic_lib import exception +from ironic_lib import json_rpc +from ironic_lib 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.""" + + allowed_exception_namespaces = [ + "ironic_lib.exception.", + "ironic.common.exception.", + "ironic_inspector.utils.", + ] + + 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, + allowed_exception_namespaces=self.allowed_exception_namespaces) + + +class _CallContext(object): + """Wrapper object for compatibility with oslo.messaging API.""" + + def __init__(self, host, serializer, version=None, version_cap=None, + allowed_exception_namespaces=()): + self.host = host + self.serializer = serializer + self.version = version + self.version_cap = version_cap + self.allowed_exception_namespaces = allowed_exception_namespaces + + def _is_known_exception(self, class_name): + for ns in self.allowed_exception_namespaces: + if class_name.startswith(ns): + return True + return False + + 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 self._is_known_exception(cls): + # 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'] = (getattr(context, 'request_id', None) + 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_lib/json_rpc/server.py b/ironic_lib/json_rpc/server.py new file mode 100644 index 00000000..2651bff0 --- /dev/null +++ b/ironic_lib/json_rpc/server.py @@ -0,0 +1,323 @@ +# 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 + +try: + from keystonemiddleware import auth_token +except ImportError: + auth_token = None +from oslo_config import cfg +from oslo_log import log +try: + import oslo_messaging +except ImportError: + oslo_messaging = None +from oslo_service import service +from oslo_service import wsgi +from oslo_utils import strutils +import webob + +from ironic_lib import auth_basic +from ironic_lib.common.i18n import _ +from ironic_lib import exception +from ironic_lib 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 EmptyContext: + + request_id = None + + def __init__(self, src): + self.__dict__.update(src) + + def to_dict(self): + return self.__dict__.copy() + + +class WSGIService(service.Service): + """Provides ability to launch JSON RPC as a WSGI application.""" + + def __init__(self, manager, serializer, context_class=EmptyContext): + """Create a JSON RPC service. + + :param manager: Object from which to expose methods. + :param serializer: A serializer that supports calls serialize_entity + and deserialize_entity. + :param context_class: A context class - a callable accepting a dict + received from network. + """ + self.manager = manager + self.serializer = serializer + self.context_class = context_class + self._method_map = _build_method_map(manager) + auth_strategy = json_rpc.auth_strategy() + if auth_strategy == 'keystone': + conf = dict(CONF.keystone_authtoken) + if auth_token is None: + raise exception.ConfigInvalid( + _("keystonemiddleware is required for keystone " + "authentication")) + 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 (oslo_messaging is not None + and 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 = self.context_class(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_lib/tests/test_json_rpc.py b/ironic_lib/tests/test_json_rpc.py new file mode 100644 index 00000000..9e929dd0 --- /dev/null +++ b/ironic_lib/tests/test_json_rpc.py @@ -0,0 +1,697 @@ +# 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 copy +import os +import tempfile +from unittest import mock + +import fixtures +import oslo_messaging +import webob + +from ironic_lib import exception +from ironic_lib.json_rpc import client +from ironic_lib.json_rpc import server +from ironic_lib.tests import base + + +class FakeContext(server.EmptyContext): + + request_id = 'abcd' + + +class FakeManager(object): + + def success(self, context, x, y=0): + assert isinstance(context, FakeContext) + assert context.user_name == 'admin' + return x - y + + def no_result(self, context): + assert isinstance(context, FakeContext) + return None + + def no_context(self): + return 42 + + def fail(self, context, message): + assert isinstance(context, FakeContext) + raise exception.IronicException(message) + + @oslo_messaging.expected_exceptions(exception.BadRequest) + def expected(self, context, message): + assert isinstance(context, FakeContext) + raise exception.BadRequest(message) + + def crash(self, context): + raise RuntimeError('boom') + + def copy(self, context, data): + return copy.deepcopy(data) + + 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 FakeSerializer: + + def serialize_entity(self, context, entity): + return entity + + def deserialize_entity(self, context, data): + return data + + +class TestService(base.IronicLibTestCase): + + 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 = FakeSerializer() + self.service = server.WSGIService(FakeManager(), self.serializer, + FakeContext) + 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, + FakeContext) + 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_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_lib.exception.BadRequest' + } + }) + + @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, + FakeContext) + 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): + data = {'ipmi_username': 'admin', 'ipmi_password': 'secret'} + node = self.serializer.serialize_entity(self.ctx, data) + body = self._request('copy', {'context': self.ctx, 'data': data}) + self.assertIsNone(body.get('error')) + node = self.serializer.deserialize_entity(self.ctx, body['result']) + logged_params = mock_log.call_args_list[0][0][2] + logged_node = logged_params['data'] + self.assertEqual({'ipmi_username': 'admin', 'ipmi_password': '***'}, + logged_node) + logged_resp = mock_log.call_args_list[1][0][2] + self.assertEqual({'ipmi_username': 'admin', 'ipmi_password': '***'}, + logged_resp) + # The result is not affected, only logging + self.assertEqual(data, node) + + +@mock.patch.object(client, '_get_session', autospec=True) +class TestClient(base.IronicLibTestCase): + + def setUp(self): + super(TestClient, self).setUp() + self.serializer = FakeSerializer() + self.client = client.Client(self.serializer) + self.context = FakeContext({'user_name': 'admin'}) + 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_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_lib.exception.BadRequest' + } + } + } + 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.BadRequest, + 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_lib.json_rpc.client.keystone', autospec=True) +class TestSession(base.IronicLibTestCase): + + 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/setup.cfg b/setup.cfg index 1e9a45c3..ca815daa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,3 +41,7 @@ oslo.config.opts = keystone = keystoneauth1>=4.2.0 # Apache-2.0 os-service-types>=1.2.0 # Apache-2.0 +json_rpc = + keystoneauth1>=4.2.0 # Apache-2.0 + os-service-types>=1.2.0 # Apache-2.0 + oslo.service!=1.28.1,>=1.24.0 # Apache-2.0 diff --git a/test-requirements.txt b/test-requirements.txt index a969435f..3dba768d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -5,6 +5,11 @@ coverage!=4.4,>=4.0 # Apache-2.0 stestr>=1.0.0 # Apache-2.0 oslotest>=3.2.0 # Apache-2.0 +fixtures>=3.0.0 # Apache-2.0/BSD # doc requirements doc8>=0.6.0 # Apache-2.0 + +# used for JSON RPC unit tests +keystonemiddleware>=4.17.0 # Apache-2.0 +oslo.messaging>=5.29.0 # Apache-2.0