Switch to JSON RPC from ironic-lib
Change-Id: I8b438861780c85faae7ff18646960723a1fd9876
This commit is contained in:
parent
2e7fc1fd61
commit
f2e8c031cc
@ -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
|
|
@ -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 '<None>'))
|
|
||||||
|
|
||||||
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})
|
|
@ -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()
|
|
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
|
from ironic_lib.json_rpc import server as json_rpc
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_log import log
|
from oslo_log import log
|
||||||
import oslo_messaging as messaging
|
import oslo_messaging as messaging
|
||||||
@ -23,7 +24,6 @@ from oslo_service import service
|
|||||||
from oslo_utils import importutils
|
from oslo_utils import importutils
|
||||||
|
|
||||||
from ironic.common import context
|
from ironic.common import context
|
||||||
from ironic.common.json_rpc import server as json_rpc
|
|
||||||
from ironic.common import rpc
|
from ironic.common import rpc
|
||||||
from ironic.objects import base as objects_base
|
from ironic.objects import base as objects_base
|
||||||
|
|
||||||
@ -51,8 +51,8 @@ class RPCService(service.Service):
|
|||||||
# Perform preparatory actions before starting the RPC listener
|
# Perform preparatory actions before starting the RPC listener
|
||||||
self.manager.prepare_host()
|
self.manager.prepare_host()
|
||||||
if CONF.rpc_transport == 'json-rpc':
|
if CONF.rpc_transport == 'json-rpc':
|
||||||
self.rpcserver = json_rpc.WSGIService(self.manager,
|
self.rpcserver = json_rpc.WSGIService(
|
||||||
serializer)
|
self.manager, serializer, context.RequestContext.from_dict)
|
||||||
else:
|
else:
|
||||||
target = messaging.Target(topic=self.topic, server=self.host)
|
target = messaging.Target(topic=self.topic, server=self.host)
|
||||||
endpoints = [self.manager]
|
endpoints = [self.manager]
|
||||||
|
@ -20,12 +20,12 @@ Client side of the conductor RPC API.
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
from ironic_lib.json_rpc import client as json_rpc
|
||||||
import oslo_messaging as messaging
|
import oslo_messaging as messaging
|
||||||
|
|
||||||
from ironic.common import exception
|
from ironic.common import exception
|
||||||
from ironic.common import hash_ring
|
from ironic.common import hash_ring
|
||||||
from ironic.common.i18n import _
|
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 release_mappings as versions
|
||||||
from ironic.common import rpc
|
from ironic.common import rpc
|
||||||
from ironic.conductor import manager
|
from ironic.conductor import manager
|
||||||
|
@ -35,7 +35,6 @@ from ironic.conf import inspector
|
|||||||
from ironic.conf import ipmi
|
from ironic.conf import ipmi
|
||||||
from ironic.conf import irmc
|
from ironic.conf import irmc
|
||||||
from ironic.conf import iscsi
|
from ironic.conf import iscsi
|
||||||
from ironic.conf import json_rpc
|
|
||||||
from ironic.conf import metrics
|
from ironic.conf import metrics
|
||||||
from ironic.conf import metrics_statsd
|
from ironic.conf import metrics_statsd
|
||||||
from ironic.conf import neutron
|
from ironic.conf import neutron
|
||||||
@ -69,7 +68,6 @@ inspector.register_opts(CONF)
|
|||||||
ipmi.register_opts(CONF)
|
ipmi.register_opts(CONF)
|
||||||
irmc.register_opts(CONF)
|
irmc.register_opts(CONF)
|
||||||
iscsi.register_opts(CONF)
|
iscsi.register_opts(CONF)
|
||||||
json_rpc.register_opts(CONF)
|
|
||||||
metrics.register_opts(CONF)
|
metrics.register_opts(CONF)
|
||||||
metrics_statsd.register_opts(CONF)
|
metrics_statsd.register_opts(CONF)
|
||||||
neutron.register_opts(CONF)
|
neutron.register_opts(CONF)
|
||||||
|
@ -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([])
|
|
@ -51,7 +51,6 @@ _opts = [
|
|||||||
('ipmi', ironic.conf.ipmi.opts),
|
('ipmi', ironic.conf.ipmi.opts),
|
||||||
('irmc', ironic.conf.irmc.opts),
|
('irmc', ironic.conf.irmc.opts),
|
||||||
('iscsi', ironic.conf.iscsi.opts),
|
('iscsi', ironic.conf.iscsi.opts),
|
||||||
('json_rpc', ironic.conf.json_rpc.list_opts()),
|
|
||||||
('metrics', ironic.conf.metrics.opts),
|
('metrics', ironic.conf.metrics.opts),
|
||||||
('metrics_statsd', ironic.conf.metrics_statsd.opts),
|
('metrics_statsd', ironic.conf.metrics_statsd.opts),
|
||||||
('neutron', ironic.conf.neutron.list_opts()),
|
('neutron', ironic.conf.neutron.list_opts()),
|
||||||
|
@ -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)
|
|
@ -10,7 +10,7 @@ WebOb>=1.7.1 # MIT
|
|||||||
python-cinderclient!=4.0.0,>=3.3.0 # Apache-2.0
|
python-cinderclient!=4.0.0,>=3.3.0 # Apache-2.0
|
||||||
python-glanceclient>=2.8.0 # Apache-2.0
|
python-glanceclient>=2.8.0 # Apache-2.0
|
||||||
keystoneauth1>=4.2.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
|
python-swiftclient>=3.2.0 # Apache-2.0
|
||||||
pytz>=2013.6 # MIT
|
pytz>=2013.6 # MIT
|
||||||
stevedore>=1.20.0 # Apache-2.0
|
stevedore>=1.20.0 # Apache-2.0
|
||||||
|
@ -5,6 +5,7 @@ namespace = ironic
|
|||||||
namespace = ironic_lib.disk_utils
|
namespace = ironic_lib.disk_utils
|
||||||
namespace = ironic_lib.disk_partitioner
|
namespace = ironic_lib.disk_partitioner
|
||||||
namespace = ironic_lib.exception
|
namespace = ironic_lib.exception
|
||||||
|
namespace = ironic_lib.json_rpc
|
||||||
namespace = ironic_lib.mdns
|
namespace = ironic_lib.mdns
|
||||||
namespace = ironic_lib.metrics
|
namespace = ironic_lib.metrics
|
||||||
namespace = ironic_lib.metrics_statsd
|
namespace = ironic_lib.metrics_statsd
|
||||||
|
Loading…
Reference in New Issue
Block a user