From 7efbbcc2d9460c087f0950fa1fbb8a9e015d5584 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Thu, 24 Jan 2019 13:22:34 +0100 Subject: [PATCH] Support using JSON-RPC instead of oslo.messaging Using AMQP-based RPC can be an overkill in case of ironic, especially when used standalone. This change allows using a built-in JSON RPC implementation instead. This implementation does not currently support the object indirection API, which does not seem to be used anywhere anyway. The standalone and API test jobs are changed to use JSON RPC. Story: #2004874 Task: #29141 Change-Id: I7cc95935d6bdae43fab5dfbd544c8e6d65fcc38d --- devstack/lib/ironic | 13 +- .../install/include/configure-ironic-api.inc | 41 ++ .../include/configure-ironic-conductor.inc | 50 ++ doc/source/install/standalone.rst | 8 + ironic/common/json_rpc/__init__.py | 20 + ironic/common/json_rpc/client.py | 185 +++++++ ironic/common/json_rpc/server.py | 283 ++++++++++ ironic/common/rpc_service.py | 13 +- ironic/conductor/rpcapi.py | 14 +- ironic/conf/__init__.py | 2 + ironic/conf/default.py | 6 + ironic/conf/json_rpc.py | 44 ++ ironic/conf/opts.py | 1 + ironic/tests/unit/common/test_json_rpc.py | 495 ++++++++++++++++++ .../notes/json-rpc-0edc429696aca6f9.yaml | 8 + zuul.d/ironic-jobs.yaml | 3 + 16 files changed, 1177 insertions(+), 9 deletions(-) create mode 100644 ironic/common/json_rpc/__init__.py create mode 100644 ironic/common/json_rpc/client.py create mode 100644 ironic/common/json_rpc/server.py create mode 100644 ironic/conf/json_rpc.py create mode 100644 ironic/tests/unit/common/test_json_rpc.py create mode 100644 releasenotes/notes/json-rpc-0edc429696aca6f9.yaml diff --git a/devstack/lib/ironic b/devstack/lib/ironic index e10ef7e714..6c3ed09aac 100644 --- a/devstack/lib/ironic +++ b/devstack/lib/ironic @@ -322,6 +322,10 @@ IRONIC_IPXE_USE_SWIFT=$(trueorfalse False IRONIC_IPXE_USE_SWIFT) IRONIC_HTTP_DIR=${IRONIC_HTTP_DIR:-$IRONIC_DATA_DIR/httpboot} IRONIC_HTTP_PORT=${IRONIC_HTTP_PORT:-3928} +# Allow using JSON RPC instead of oslo.messaging +IRONIC_RPC_TRANSPORT=${IRONIC_RPC_TRANSPORT:-oslo} +IRONIC_JSON_RPC_PORT=${IRONIC_JSON_RPC_PORT:-8089} + # Whether DevStack will be setup for bare metal or VMs IRONIC_IS_HARDWARE=$(trueorfalse False IRONIC_IS_HARDWARE) @@ -1107,6 +1111,9 @@ function configure_ironic { iniset $IRONIC_CONF_FILE agent deploy_logs_local_path $IRONIC_DEPLOY_LOGS_LOCAL_PATH # Set image_download_source for direct interface iniset $IRONIC_CONF_FILE agent image_download_source $IRONIC_AGENT_IMAGE_DOWNLOAD_SOURCE + # Configure JSON RPC backend + iniset $IRONIC_CONF_FILE DEFAULT rpc_transport $IRONIC_RPC_TRANSPORT + iniset $IRONIC_CONF_FILE json_rpc port $IRONIC_JSON_RPC_PORT # Configure Ironic conductor, if it was enabled. if is_service_enabled ir-cond; then @@ -1171,7 +1178,9 @@ function configure_ironic_api { iniset $IRONIC_CONF_FILE DEFAULT auth_strategy $IRONIC_AUTH_STRATEGY configure_auth_token_middleware $IRONIC_CONF_FILE ironic $IRONIC_AUTH_CACHE_DIR/api - iniset_rpc_backend ironic $IRONIC_CONF_FILE + if is_service_enabled rabbit; then + iniset_rpc_backend ironic $IRONIC_CONF_FILE + fi iniset $IRONIC_CONF_FILE conductor automated_clean $IRONIC_AUTOMATED_CLEAN_ENABLED @@ -1212,7 +1221,7 @@ function configure_ironic_conductor { # NOTE(pas-ha) service_catalog section is used to discover # ironic API endpoint from keystone catalog - local client_sections="neutron swift glance inspector cinder service_catalog" + local client_sections="neutron swift glance inspector cinder service_catalog json_rpc" for conf_section in $client_sections; do configure_client_for $conf_section done diff --git a/doc/source/install/include/configure-ironic-api.inc b/doc/source/install/include/configure-ironic-api.inc index 52adb1d918..301558a4b0 100644 --- a/doc/source/install/include/configure-ironic-api.inc +++ b/doc/source/install/include/configure-ironic-api.inc @@ -29,6 +29,47 @@ Configuring ironic-api service # configuration. (string value) transport_url = rabbit://RPC_USER:RPC_PASSWORD@RPC_HOST:RPC_PORT/ + Alternatively, you can use JSON RPC for interactions between + ironic-conductor and ironic-api. Enable it in the configuration and provide + the keystone credentials to use for authentication: + + .. code-block:: ini + + [DEFAULT] + + rpc_transport = json-rpc + + [json_rpc] + + # Authentication type to load (string value) + auth_type = password + + # Authentication URL (string value) + auth_url=https://IDENTITY_IP:5000/ + + # Username (string value) + username=ironic + + # User's password (string value) + password=IRONIC_PASSWORD + + # Project name to scope to (string value) + project_name=service + + # Domain ID containing project (string value) + project_domain_id=default + + # User's domain id (string value) + user_domain_id=default + + If you use port other than the default 8089 for JSON RPC, you have to + configure it, for example: + + .. code-block:: ini + + [json_rpc] + port = 9999 + #. Configure the ironic-api service to use these credentials with the Identity service. Replace ``PUBLIC_IDENTITY_IP`` with the public IP of the Identity server, ``PRIVATE_IDENTITY_IP`` with the private IP of the Identity server diff --git a/doc/source/install/include/configure-ironic-conductor.inc b/doc/source/install/include/configure-ironic-conductor.inc index fea1303a93..00aaf21f4d 100644 --- a/doc/source/install/include/configure-ironic-conductor.inc +++ b/doc/source/install/include/configure-ironic-conductor.inc @@ -42,6 +42,56 @@ Configuring ironic-conductor service # configuration. (string value) transport_url = rabbit://RPC_USER:RPC_PASSWORD@RPC_HOST:RPC_PORT/ + Alternatively, you can use JSON RPC for interactions between + ironic-conductor and ironic-api. Enable it in the configuration and provide + the keystone credentials to use for authenticating incoming requests (can + be the same as for the API): + + .. code-block:: ini + + [DEFAULT] + + rpc_transport = json-rpc + + [keystone_authtoken] + + # Authentication type to load (string value) + auth_type=password + + # Complete public Identity API endpoint (string value) + www_authenticate_uri=http://PUBLIC_IDENTITY_IP:5000 + + # Complete admin Identity API endpoint. (string value) + auth_url=http://PRIVATE_IDENTITY_IP:5000 + + # Service username. (string value) + username=ironic + + # Service account password. (string value) + password=IRONIC_PASSWORD + + # Service tenant name. (string value) + project_name=service + + # Domain name containing project (string value) + project_domain_name=Default + + # User's domain name (string value) + user_domain_name=Default + + You can optionally change the host and the port the JSON RPC service will + bind to, for example: + + .. code-block:: ini + + [json_rpc] + host_ip = 192.168.0.10 + port = 9999 + + .. warning:: + Hostnames of ironic-conductor machines must be resolvable by ironic-api + services when JSON RPC is used. + #. Configure credentials for accessing other OpenStack services. In order to communicate with other OpenStack services, the Bare Metal diff --git a/doc/source/install/standalone.rst b/doc/source/install/standalone.rst index 3f9e20783f..f2618723c1 100644 --- a/doc/source/install/standalone.rst +++ b/doc/source/install/standalone.rst @@ -30,6 +30,14 @@ You should make the following changes to ``/etc/ironic/ironic.conf``: Networking since it will do all the dynamically changing configurations for you. +#. If you want to disable using a messaging broker between conductor and API + processes, switch to JSON RPC instead: + + .. code-block:: ini + + [DEFAULT] + rpc_transport = json-rpc + If you don't use Image service, it's possible to provide images to Bare Metal service via a URL. diff --git a/ironic/common/json_rpc/__init__.py b/ironic/common/json_rpc/__init__.py new file mode 100644 index 0000000000..280b93f623 --- /dev/null +++ b/ironic/common/json_rpc/__init__.py @@ -0,0 +1,20 @@ +# 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 require_authentication(): + return (CONF.json_rpc.auth_strategy or CONF.auth_strategy) == 'keystone' diff --git a/ironic/common/json_rpc/client.py b/ironic/common/json_rpc/client.py new file mode 100644 index 0000000000..c524c43f46 --- /dev/null +++ b/ironic/common/json_rpc/client.py @@ -0,0 +1,185 @@ +# 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 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: + if json_rpc.require_authentication(): + auth = keystone.get_auth('json_rpc') + else: + auth = None + + _SESSION = keystone.get_session('json_rpc', auth=auth) + _SESSION.headers = { + 'Content-Type': 'application/json' + } + + 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, body) + url = 'http://%s:%d' % (self.host, CONF.json_rpc.port) + result = _get_session().post(url, json=body) + LOG.debug('RPC %s returned %s', method, 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 new file mode 100644 index 0000000000..3d9fe6ef1c --- /dev/null +++ b/ironic/common/json_rpc/server.py @@ -0,0 +1,283 @@ +# 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 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 +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__) +_BLACK_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 _BLACK_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) + if json_rpc.require_authentication(): + conf = dict(CONF.keystone_authtoken) + app = auth_token.AuthProtocol(self._application, conf) + 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.require_authentication(): + 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) + + 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, 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, 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 a2aebb56c4..9df03b9179 100644 --- a/ironic/common/rpc_service.py +++ b/ironic/common/rpc_service.py @@ -16,16 +16,19 @@ import signal +from oslo_config import cfg from oslo_log import log import oslo_messaging as messaging 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 LOG = log.getLogger(__name__) +CONF = cfg.CONF class RPCService(service.Service): @@ -44,10 +47,14 @@ class RPCService(service.Service): super(RPCService, self).start() admin_context = context.get_admin_context() - target = messaging.Target(topic=self.topic, server=self.host) - endpoints = [self.manager] serializer = objects_base.IronicObjectSerializer(is_server=True) - self.rpcserver = rpc.get_server(target, endpoints, serializer) + if CONF.rpc_transport == 'json-rpc': + self.rpcserver = json_rpc.WSGIService(self.manager, + serializer) + else: + target = messaging.Target(topic=self.topic, server=self.host) + endpoints = [self.manager] + self.rpcserver = rpc.get_server(target, endpoints, serializer) self.rpcserver.start() self.handle_signal() diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index 661047f1fa..123bd3d372 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -25,6 +25,7 @@ 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 @@ -112,14 +113,19 @@ class ConductorAPI(object): if self.topic is None: self.topic = manager.MANAGER_TOPIC - target = messaging.Target(topic=self.topic, - version='1.0') serializer = objects_base.IronicObjectSerializer() release_ver = versions.RELEASE_MAPPING.get(CONF.pin_release_version) version_cap = (release_ver['rpc'] if release_ver else self.RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap=version_cap, - serializer=serializer) + + if CONF.rpc_transport == 'json-rpc': + self.client = json_rpc.Client(serializer=serializer, + version_cap=version_cap) + self.topic = '' + else: + target = messaging.Target(topic=self.topic, version='1.0') + self.client = rpc.get_client(target, version_cap=version_cap, + serializer=serializer) use_groups = self.client.can_send_version('1.47') # NOTE(deva): this is going to be buggy diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index d3fd5e90b7..a2d4ae4b63 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -35,6 +35,7 @@ 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 @@ -67,6 +68,7 @@ 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/default.py b/ironic/conf/default.py index 86ec8c06df..0a835beaa9 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -315,6 +315,12 @@ service_opts = [ 'When doing a rolling upgrade from version N to version ' 'N+1, set (to pin) this to N. To unpin (default), leave ' 'it unset and the latest versions will be used.')), + cfg.StrOpt('rpc_transport', + default='oslo', + choices=[('oslo', _('use oslo.messaging transport')), + ('json-rpc', _('use JSON RPC transport'))], + help=_('Which RPC transport implementation to use between ' + 'conductor and API services')), ] utils_opts = [ diff --git a/ironic/conf/json_rpc.py b/ironic/conf/json_rpc.py new file mode 100644 index 0000000000..6c5d007616 --- /dev/null +++ b/ironic/conf/json_rpc.py @@ -0,0 +1,44 @@ +# 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'))], + help=_('Authentication strategy used by JSON RPC. Defaults to ' + 'the global auth_strategy setting.')), + cfg.HostAddressOpt('host_ip', + default='0.0.0.0', + 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')), +] + + +def register_opts(conf): + conf.register_opts(opts, group='json_rpc') + auth.register_auth_opts(conf, 'json_rpc') + + +def list_opts(): + return opts + auth.add_auth_opts([]) diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index 79456bec3e..7948497338 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -53,6 +53,7 @@ _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 new file mode 100644 index 0000000000..082eaa0a0b --- /dev/null +++ b/ironic/tests/unit/common/test_json_rpc.py @@ -0,0 +1,495 @@ +# 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 fixtures +import mock +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.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: + self.assertFalse(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 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.assertIsNone(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.common.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_blacklisted_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(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_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_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) diff --git a/releasenotes/notes/json-rpc-0edc429696aca6f9.yaml b/releasenotes/notes/json-rpc-0edc429696aca6f9.yaml new file mode 100644 index 0000000000..de3cd985a4 --- /dev/null +++ b/releasenotes/notes/json-rpc-0edc429696aca6f9.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Adds the ability to use JSON RPC for communication between API and + conductor services. To use it set the new ``rpc_transport`` configuration + options to ``json-rpc`` and configure the credentials and the ``host_ip`` + in the ``json_rpc`` section. Hostnames of all conductors must be + resolvable for this implementation to work. diff --git a/zuul.d/ironic-jobs.yaml b/zuul.d/ironic-jobs.yaml index a5461c3c7e..af0bad7f51 100644 --- a/zuul.d/ironic-jobs.yaml +++ b/zuul.d/ironic-jobs.yaml @@ -92,6 +92,7 @@ IRONIC_AUTOMATED_CLEAN_ENABLED: False IRONIC_DEFAULT_DEPLOY_INTERFACE: direct IRONIC_ENABLED_DEPLOY_INTERFACES: "iscsi,direct,ansible" + IRONIC_RPC_TRANSPORT: json-rpc IRONIC_VM_COUNT: 6 SWIFT_ENABLE_TEMPURLS: True SWIFT_TEMPURL_KEY: secretkey @@ -269,7 +270,9 @@ IRONIC_DEFAULT_NETWORK_INTERFACE: noop IRONIC_TEMPEST_WHOLE_DISK_IMAGE: True IRONIC_VM_EPHEMERAL_DISK: 0 + IRONIC_RPC_TRANSPORT: json-rpc devstack_services: + rabbit: False g-api: False g-reg: False n-api: False