Merge "Migrate ironic_lib to ironic"

This commit is contained in:
Zuul 2025-01-14 17:34:01 +00:00 committed by Gerrit Code Review
commit 75b84f6638
106 changed files with 4032 additions and 263 deletions

View File

@ -35,7 +35,6 @@ set +o pipefail
# Set up default directories
GITDIR["python-ironicclient"]=$DEST/python-ironicclient
GITDIR["ironic-lib"]=$DEST/ironic-lib
GITREPO["pyghmi"]=${PYGHMI_REPO:-${GIT_BASE}/x/pyghmi}
GITBRANCH["pyghmi"]=${PYGHMI_BRANCH:-master}
@ -1200,11 +1199,6 @@ function install_ironic {
done
fi
if use_library_from_git "ironic-lib"; then
git_clone_by_name "ironic-lib"
setup_dev_lib "ironic-lib"
fi
setup_develop $IRONIC_DIR
install_apache_wsgi
@ -2956,9 +2950,6 @@ function build_tinyipa_ramdisk {
export AUTHORIZE_SSH=true
export SSH_PUBLIC_KEY=$IRONIC_ANSIBLE_SSH_KEY.pub
fi
if [ -e $DEST/ironic-lib ]; then
export IRONIC_LIB_SOURCE="$DEST/ironic-lib"
fi
make
cp tinyipa.gz $ramdisk_path
cp tinyipa.vmlinuz $kernel_path
@ -3009,12 +3000,6 @@ function build_ipa_dib_ramdisk {
install_diskimage_builder
fi
if [ -e $DEST/ironic-lib ]; then
export IRONIC_LIB_FROM_SOURCE=true
export DIB_REPOLOCATION_ironic_lib=$DEST/ironic-lib
export DIB_REPOREF_ironic_lib=$TARGET_BRANCH
fi
echo "Building IPA ramdisk with DIB options: $IRONIC_DIB_RAMDISK_OPTIONS"
if is_deploy_iso_required; then
IRONIC_DIB_RAMDISK_OPTIONS+=" iso"

View File

@ -87,8 +87,6 @@ or via notifier plugin (such as is done with ironic-prometheus-exporter).
Ironic service model. A separate webserver process presently does not have
the capability of triggering the call to retrieve and transmit the data.
.. NOTE::
This functionality requires ironic-lib version 5.4.0 to be installed.
Types of Metrics Emitted
========================

View File

@ -15,7 +15,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from ironic_lib import auth_basic
import keystonemiddleware.audit as audit_middleware
from keystonemiddleware import auth_token
from oslo_config import cfg
@ -31,6 +30,7 @@ from ironic.api import hooks
from ironic.api import middleware
from ironic.api.middleware import auth_public_routes
from ironic.api.middleware import json_ext
from ironic.common import auth_basic
from ironic.common import exception
from ironic.conf import CONF

View File

@ -12,7 +12,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_config import cfg
from oslo_utils import uuidutils
import pecan
@ -27,6 +26,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic import objects

View File

@ -13,7 +13,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from ironic_lib import metrics_utils
from pecan import rest
from ironic import api
@ -21,6 +20,7 @@ from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common import metrics_utils
from ironic import objects
METRICS = metrics_utils.get_metrics_logger(__name__)

View File

@ -15,7 +15,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_utils import uuidutils
from pecan import rest
@ -29,6 +28,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic import objects
METRICS = metrics_utils.get_metrics_logger(__name__)

View File

@ -10,7 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from ironic_lib import metrics_utils
from oslo_log import log
from oslo_utils import timeutils
from pecan import rest
@ -22,6 +21,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
import ironic.conf
from ironic import objects

View File

@ -12,7 +12,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_log import log
from oslo_utils import strutils
from oslo_utils import uuidutils
@ -29,6 +28,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
import ironic.conf
from ironic import objects

View File

@ -15,7 +15,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from pecan import rest
from ironic import api
@ -25,6 +24,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic.drivers import base as driver_base

View File

@ -12,7 +12,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_log import log
import pecan
@ -20,6 +19,7 @@ from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common import metrics_utils
METRICS = metrics_utils.get_metrics_logger(__name__)

View File

@ -13,13 +13,13 @@
# License for the specific language governing permissions and limitations
# under the License.
from ironic_lib import metrics_utils
from pecan import rest
from ironic import api
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import method
from ironic.common import args
from ironic.common import metrics_utils
from ironic import objects
METRICS = metrics_utils.get_metrics_logger(__name__)

View File

@ -19,7 +19,6 @@ from http import client as http_client
import json
import urllib.parse
from ironic_lib import metrics_utils
import jsonschema
from jsonschema import exceptions as json_schema_exc
from oslo_log import log
@ -46,6 +45,7 @@ from ironic.common import boot_devices
from ironic.common import boot_modes
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic.common import policy
from ironic.common import states as ir_states
from ironic.conductor import steps as conductor_steps

View File

@ -15,7 +15,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_log import log
from oslo_utils import uuidutils
from pecan import rest
@ -29,6 +28,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic.common import states as ir_states
from ironic import objects

View File

@ -12,7 +12,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_utils import uuidutils
import pecan
@ -26,6 +25,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic.common import states as ir_states
from ironic import objects

View File

@ -12,7 +12,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_log import log
from oslo_utils import strutils
from oslo_utils import uuidutils
@ -29,6 +28,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
import ironic.conf
from ironic import objects

View File

@ -10,7 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from ironic_lib import metrics_utils
from oslo_config import cfg
import pecan
@ -20,6 +19,7 @@ from ironic.api.controllers.v1 import versions
from ironic.api import method
from ironic.api import validation
from ironic.common.i18n import _
from ironic.common import metrics_utils
CONF = cfg.CONF

View File

@ -14,7 +14,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_utils import uuidutils
from pecan import rest
@ -27,6 +26,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic import objects
METRICS = metrics_utils.get_metrics_logger(__name__)

View File

@ -14,7 +14,6 @@
from http import client as http_client
from ironic_lib import metrics_utils
from oslo_utils import uuidutils
from pecan import rest
@ -27,6 +26,7 @@ from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import metrics_utils
from ironic.common import policy
from ironic import objects

203
ironic/common/auth_basic.py Normal file
View File

@ -0,0 +1,203 @@
# Copyright 2020 Red Hat, Inc.
# All Rights Reserved.
#
# 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 base64
import binascii
import logging
import bcrypt
import webob
from ironic.common import exception
from ironic.common.i18n import _
LOG = logging.getLogger(__name__)
class BasicAuthMiddleware(object):
"""Middleware which performs HTTP basic authentication on requests
"""
def __init__(self, app, auth_file):
self.app = app
self.auth_file = auth_file
validate_auth_file(auth_file)
def format_exception(self, e):
result = {'error': {'message': str(e), 'code': e.code}}
headers = list(e.headers.items()) + [
('Content-Type', 'application/json')
]
return webob.Response(content_type='application/json',
status_code=e.code,
json_body=result,
headerlist=headers)
def __call__(self, env, start_response):
try:
token = parse_header(env)
username, password = parse_token(token)
env.update(authenticate(self.auth_file, username, password))
return self.app(env, start_response)
except exception.IronicException as e:
response = self.format_exception(e)
return response(env, start_response)
def authenticate(auth_file, username, password):
"""Finds username and password match in Apache style user auth file
The user auth file format is expected to comply with Apache
documentation[1] however the bcrypt password digest is the *only*
digest format supported.
[1] https://httpd.apache.org/docs/current/misc/password_encryptions.html
:param: auth_file: Path to user auth file
:param: username: Username to authenticate
:param: password: Password encoded as bytes
:returns: A dictionary of WSGI environment values to append to the request
:raises: Unauthorized, if no file entries match supplied username/password
"""
line_prefix = username + ':'
try:
with open(auth_file, 'r') as f:
for line in f:
entry = line.strip()
if entry and entry.startswith(line_prefix):
return auth_entry(entry, password)
except OSError as exc:
LOG.error('Problem reading auth user file: %s', exc)
raise exception.ConfigInvalid(
error_msg=_('Problem reading auth user file'))
# reached end of file with no matches
LOG.info('User %s not found', username)
unauthorized()
def auth_entry(entry, password):
"""Compare a password with a single user auth file entry
:param: entry: Line from auth user file to use for authentication
:param: password: Password encoded as bytes
:returns: A dictionary of WSGI environment values to append to the request
:raises: Unauthorized, if the entry doesn't match supplied password or
if the entry is encrypted with a method other than bcrypt
"""
username, encrypted = parse_entry(entry)
if not bcrypt.checkpw(password, encrypted):
LOG.info('Password for %s does not match', username)
unauthorized()
return {
'HTTP_X_USER': username,
'HTTP_X_USER_NAME': username
}
def validate_auth_file(auth_file):
"""Read the auth user file and validate its correctness
:param: auth_file: Path to user auth file
:raises: ConfigInvalid on validation error
"""
try:
with open(auth_file, 'r') as f:
for line in f:
entry = line.strip()
if entry and ':' in entry:
parse_entry(entry)
except OSError:
raise exception.ConfigInvalid(
error_msg=_('Problem reading auth user file: %s') % auth_file)
def parse_entry(entry):
"""Extrace the username and encrypted password from a user auth file entry
:param: entry: Line from auth user file to use for authentication
:returns: a tuple of username and encrypted password
:raises: ConfigInvalid if the password is not in the supported bcrypt
format
"""
username, encrypted_str = entry.split(':', maxsplit=1)
encrypted = encrypted_str.encode('utf-8')
if encrypted[:4] not in (b'$2y$', b'$2a$', b'$2b$'):
error_msg = _('Only bcrypt digested passwords are supported for '
'%(username)s') % {'username': username}
raise exception.ConfigInvalid(error_msg=error_msg)
return username, encrypted
def parse_token(token):
"""Parse the token portion of the Authentication header value
:param: token: Token value from basic authorization header
:returns: tuple of username, password
:raises: Unauthorized, if username and password could not be parsed for any
reason
"""
try:
if isinstance(token, str):
token = token.encode('utf-8')
auth_pair = base64.b64decode(token, validate=True)
(username, password) = auth_pair.split(b':', maxsplit=1)
return (username.decode('utf-8'), password)
except (TypeError, binascii.Error, ValueError) as exc:
LOG.info('Could not decode authorization token: %s', exc)
raise exception.BadRequest(_('Could not decode authorization token'))
def parse_header(env):
"""Parse WSGI environment for Authorization header of type Basic
:param: env: WSGI environment to get header from
:returns: Token portion of the header value
:raises: Unauthorized, if header is missing or if the type is not Basic
"""
try:
auth_header = env.pop('HTTP_AUTHORIZATION')
except KeyError:
LOG.info('No authorization token received')
unauthorized(_('Authorization required'))
try:
auth_type, token = auth_header.strip().split(maxsplit=1)
except (ValueError, AttributeError) as exc:
LOG.info('Could not parse Authorization header: %s', exc)
raise exception.BadRequest(_('Could not parse Authorization header'))
if auth_type.lower() != 'basic':
msg = _('Unsupported authorization type "%s"') % auth_type
LOG.info(msg)
raise exception.BadRequest(msg)
return token
def unauthorized(message=None):
"""Raise an Unauthorized exception to prompt for basic authentication
:param: message: Optional message for esception
:raises: Unauthorized with WWW-Authenticate header set
"""
if not message:
message = _('Incorrect username or password')
raise exception.Unauthorized(message)

View File

@ -15,17 +15,117 @@
# under the License.
"""Ironic specific exceptions list."""
import collections
from http import client as http_client
import json
from ironic_lib.exception import IronicException
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import excutils
from ironic.common.i18n import _
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def _ensure_exception_kwargs_serializable(exc_class_name, kwargs):
"""Ensure that kwargs are serializable
Ensure that all kwargs passed to exception constructor can be passed over
RPC, by trying to convert them to JSON, or, as a last resort, to string.
If it is not possible, unserializable kwargs will be removed, letting the
receiver handle the exception string as it is configured to.
:param exc_class_name: a IronicException class name.
:param kwargs: a dictionary of keyword arguments passed to the exception
constructor.
:returns: a dictionary of serializable keyword arguments.
"""
serializers = [(json.dumps, _('when converting to JSON')),
(str, _('when converting to string'))]
exceptions = collections.defaultdict(list)
serializable_kwargs = {}
for k, v in kwargs.items():
for serializer, msg in serializers:
try:
serializable_kwargs[k] = serializer(v)
exceptions.pop(k, None)
break
except Exception as e:
exceptions[k].append(
'(%(serializer_type)s) %(e_type)s: %(e_contents)s' %
{'serializer_type': msg, 'e_contents': e,
'e_type': e.__class__.__name__})
if exceptions:
LOG.error("One or more arguments passed to the %(exc_class)s "
"constructor as kwargs can not be serialized. The "
"serialized arguments: %(serialized)s. These "
"unserialized kwargs were dropped because of the "
"exceptions encountered during their "
"serialization:\n%(errors)s",
dict(errors=';\n'.join("%s: %s" % (k, '; '.join(v))
for k, v in exceptions.items()),
exc_class=exc_class_name,
serialized=serializable_kwargs))
# We might be able to actually put the following keys' values into
# format string, but there is no guarantee, drop it just in case.
for k in exceptions:
del kwargs[k]
return serializable_kwargs
class IronicException(Exception):
"""Base Ironic Exception
To correctly use this class, inherit from it and define
a '_msg_fmt' property. That _msg_fmt will get printf'd
with the keyword arguments provided to the constructor.
If you need to access the message from an exception you should use
str(exc)
"""
_msg_fmt = _("An unknown exception occurred.")
code = 500
headers = {}
safe = False
def __init__(self, message=None, **kwargs):
self.kwargs = _ensure_exception_kwargs_serializable(
self.__class__.__name__, kwargs)
if 'code' not in self.kwargs:
try:
self.kwargs['code'] = self.code
except AttributeError:
pass
else:
self.code = int(kwargs['code'])
if not message:
try:
message = self._msg_fmt % kwargs
except Exception:
with excutils.save_and_reraise_exception() as ctxt:
# kwargs doesn't match a variable in the message
# log the issue and the kwargs
prs = ', '.join('%s=%s' % pair for pair in kwargs.items())
LOG.exception('Exception in string format operation '
'(arguments %s)', prs)
if not CONF.errors.fatal_exception_format_errors:
# at least get the core message out if something
# happened
message = self._msg_fmt
ctxt.reraise = False
super(IronicException, self).__init__(message)
class NotAuthorized(IronicException):
_msg_fmt = _("Not authorized.")
code = http_client.FORBIDDEN
@ -861,8 +961,7 @@ class ImageRefIsARedirect(IronicException):
def __init__(self, image_ref=None, redirect_url=None, msg=None):
self.redirect_url = redirect_url
# Kwargs are expected by ironic_lib's IronicException to convert
# the message.
# Kwargs are expected by IronicException to convert the message.
super(ImageRefIsARedirect, self).__init__(
message=msg,
image_ref=image_ref,
@ -945,3 +1044,21 @@ class ChildNodeLocked(Conflict):
"and we are unable to perform any action on it at this "
"time. Please retry after the current operation is "
"completed.")
class MetricsNotSupported(IronicException):
_msg_fmt = _("Metrics action is not supported. You may need to "
"adjust the [metrics] section in ironic.conf.")
class ServiceLookupFailure(IronicException):
_msg_fmt = _("Cannot find %(service)s service through multicast.")
class ServiceRegistrationFailure(IronicException):
_msg_fmt = _("Cannot register %(service)s service: %(error)s")
class Unauthorized(IronicException):
code = http_client.UNAUTHORIZED
headers = {'WWW-Authenticate': 'Basic realm="Baremetal API"'}

View File

@ -15,7 +15,6 @@ import os.path
import shutil
from urllib import parse as urlparse
from ironic_lib import utils as ironic_utils
from oslo_log import log
from ironic.common import exception
@ -114,7 +113,7 @@ class LocalPublisher(AbstractPublisher):
def unpublish(self, file_name):
published_file = os.path.join(
CONF.deploy.http_root, self.image_subdir, file_name)
ironic_utils.unlink_without_raise(published_file)
utils.unlink_without_raise(published_file)
class SwiftPublisher(AbstractPublisher):

View File

@ -101,7 +101,7 @@ def create_vfat_image(output_file, files_info=None, parameters=None,
mounting, creating filesystem, copying files, etc.
"""
try:
# TODO(sbaker): use ironic_lib.utils.dd when rootwrap has been removed
# TODO(sbaker): use utils.dd when rootwrap has been removed
utils.execute('dd', 'if=/dev/zero', 'of=%s' % output_file, 'count=1',
'bs=%dKiB' % fs_size_kib)
except processutils.ProcessExecutionError as e:
@ -113,8 +113,7 @@ def create_vfat_image(output_file, files_info=None, parameters=None,
# The label helps ramdisks to find the partition containing
# the parameters (by using /dev/disk/by-label/ir-vfd-dev).
# NOTE: FAT filesystem label can be up to 11 characters long.
# TODO(sbaker): use ironic_lib.utils.mkfs when rootwrap has been
# removed
# TODO(sbaker): use utils.mkfs when rootwrap has been removed
utils.execute('mkfs', '-t', 'vfat', '-n',
'ir-vfd-dev', output_file)
except processutils.ProcessExecutionError as e:

View File

View File

@ -0,0 +1,241 @@
# 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.
"""
import logging
from oslo_config import cfg
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 keystone
from ironic.conf import json_rpc
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
_SESSION = None
def _get_session():
global _SESSION
if _SESSION is None:
kwargs = {}
auth_strategy = json_rpc.auth_strategy()
if auth_strategy != 'keystone':
auth_type = 'none' if auth_strategy == 'noauth' else auth_strategy
CONF.set_default('auth_type', auth_type, group='json_rpc')
# Deprecated, remove in W
if auth_strategy == 'http_basic':
if CONF.json_rpc.http_basic_username:
kwargs['username'] = CONF.json_rpc.http_basic_username
if CONF.json_rpc.http_basic_password:
kwargs['password'] = CONF.json_rpc.http_basic_password
auth = keystone.get_auth('json_rpc', **kwargs)
session = keystone.get_session('json_rpc', auth=auth)
headers = {
'Content-Type': 'application/json'
}
# Adds options like connect_retries
_SESSION = keystone.get_adapter('json_rpc', session=session,
additional_headers=headers)
return _SESSION
class Client(object):
"""JSON RPC client with ironic exception handling."""
allowed_exception_namespaces = [
"ironic.common.exception.",
"ironic_inspector.utils.",
]
def __init__(self, serializer, version_cap=None):
self.serializer = serializer
self.version_cap = version_cap
def can_send_version(self, version):
return _can_send_version(version, self.version_cap)
def prepare(self, topic, version=None):
"""Prepare the client to transmit a request.
:param topic: Topic which is being addressed. Typically this
is the hostname of the remote json-rpc service.
:param version: The RPC API version to utilize.
"""
host = topic.split('.', 1)[1]
host, port = netutils.parse_host_port(host)
return _CallContext(
host, self.serializer, version=version,
version_cap=self.version_cap,
allowed_exception_namespaces=self.allowed_exception_namespaces,
port=port)
class _CallContext(object):
"""Wrapper object for compatibility with oslo.messaging API."""
def __init__(self, host, serializer, version=None, version_cap=None,
allowed_exception_namespaces=(), port=None):
if not port:
self.port = CONF.json_rpc.port
else:
self.port = int(port)
self.host = host
self.serializer = serializer
self.version = version
self.version_cap = version_cap
self.allowed_exception_namespaces = allowed_exception_namespaces
def _is_known_exception(self, class_name):
for ns in self.allowed_exception_namespaces:
if class_name.startswith(ns):
return True
return False
def _handle_error(self, error):
if not error:
return
message = error['message']
try:
cls = error['data']['class']
except KeyError:
LOG.error("Unexpected error from RPC: %s", error)
raise exception.IronicException(
_("Unexpected error raised by RPC"))
else:
if not self._is_known_exception(cls):
# NOTE(dtantsur): protect against arbitrary code execution
LOG.error("Unexpected error from RPC: %s", error)
raise exception.IronicException(
_("Unexpected error raised by RPC"))
raise importutils.import_object(cls, message,
code=error.get('code', 500))
def call(self, context, method, version=None, **kwargs):
"""Call conductor RPC.
Versioned objects are automatically serialized and deserialized.
:param context: Security context.
:param method: Method name.
:param version: RPC API version to use.
:param kwargs: Keyword arguments to pass.
:return: RPC result (if any).
"""
return self._request(context, method, cast=False, version=version,
**kwargs)
def cast(self, context, method, version=None, **kwargs):
"""Call conductor RPC asynchronously.
Versioned objects are automatically serialized and deserialized.
:param context: Security context.
:param method: Method name.
:param version: RPC API version to use.
:param kwargs: Keyword arguments to pass.
:return: None
"""
return self._request(context, method, cast=True, version=version,
**kwargs)
def _request(self, context, method, cast=False, version=None, **kwargs):
"""Call conductor RPC.
Versioned objects are automatically serialized and deserialized.
:param context: Security context.
:param method: Method name.
:param cast: If true, use a JSON RPC notification.
:param version: RPC API version to use.
:param kwargs: Keyword arguments to pass.
:return: RPC result (if any).
"""
params = {key: self.serializer.serialize_entity(context, value)
for key, value in kwargs.items()}
params['context'] = context.to_dict()
if version is None:
version = self.version
if version is not None:
_check_version(version, self.version_cap)
params['rpc.version'] = version
body = {
"jsonrpc": "2.0",
"method": method,
"params": params,
}
if not cast:
body['id'] = (getattr(context, 'request_id', None)
or uuidutils.generate_uuid())
scheme = 'http'
if CONF.json_rpc.use_ssl:
scheme = 'https'
url = '%s://%s:%d' % (scheme,
netutils.escape_ipv6(self.host),
self.port)
LOG.debug("RPC %s to %s with %s", method, url,
strutils.mask_dict_password(body))
try:
result = _get_session().post(url, json=body)
except Exception as exc:
LOG.debug('RPC %s to %s failed with %s', method, url, exc)
raise
LOG.debug('RPC %s to %s returned %s', method, url,
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})

View File

@ -0,0 +1,281 @@
# 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
import logging
from keystonemiddleware import auth_token
from oslo_config import cfg
import oslo_messaging
from oslo_utils import strutils
import webob
from ironic.common import auth_basic
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common.json_rpc import wsgi
from ironic.conf import json_rpc
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
_DENY_LIST = {'init_host', 'del_host', 'target', 'iter_nodes'}
def _build_method_map(manager):
"""Build mapping from method names to their bodies.
:param manager: A conductor manager.
:return: dict with mapping
"""
result = {}
for method in dir(manager):
if method.startswith('_') or method in _DENY_LIST:
continue
func = getattr(manager, method)
if not callable(func):
continue
LOG.debug('Adding RPC method %s', method)
result[method] = func
return result
class JsonRpcError(exception.IronicException):
pass
class ParseError(JsonRpcError):
code = -32700
_msg_fmt = _("Invalid JSON received by RPC server")
class InvalidRequest(JsonRpcError):
code = -32600
_msg_fmt = _("Invalid request object received by RPC server")
class MethodNotFound(JsonRpcError):
code = -32601
_msg_fmt = _("Method %(name)s was not found")
class InvalidParams(JsonRpcError):
code = -32602
_msg_fmt = _("Params %(params)s are invalid for %(method)s: %(error)s")
class EmptyContext:
request_id = None
def __init__(self, src):
self.__dict__.update(src)
def to_dict(self):
return self.__dict__.copy()
class WSGIService(wsgi.WSGIService):
"""Provides ability to launch JSON RPC as a WSGI application."""
def __init__(self, manager, serializer, context_class=EmptyContext):
"""Create a JSON RPC service.
:param manager: Object from which to expose methods.
:param serializer: A serializer that supports calls serialize_entity
and deserialize_entity.
:param context_class: A context class - a callable accepting a dict
received from network.
"""
self.manager = manager
self.serializer = serializer
self.context_class = context_class
self._method_map = _build_method_map(manager)
auth_strategy = json_rpc.auth_strategy()
if auth_strategy == 'keystone':
conf = dict(CONF.keystone_authtoken)
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
super().__init__('ironic-json-rpc', app, CONF.json_rpc)
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(',')
allowed_roles = CONF.json_rpc.allowed_roles
if set(roles).isdisjoint(allowed_roles):
LOG.debug('Roles %s do not contain any of %s, rejecting '
'request', roles, allowed_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 = self.context_class(context)
params = {key: self.serializer.deserialize_entity(context, value)
for key, value in params.items()}
params['context'] = context
LOG.debug('RPC %s with %s', name, logged_params)
try:
result = func(**params)
# FIXME(dtantsur): we could use the inspect module, but
# oslo_messaging.expected_exceptions messes up signatures.
except TypeError as exc:
raise InvalidParams(params=', '.join(params),
method=name, error=exc)
if context is not None:
# Currently it seems that we can serialize even with invalid
# context, but I'm not sure it's guaranteed to be the case.
result = self.serializer.serialize_entity(context, result)
LOG.debug('RPC %s returned %s', name,
strutils.mask_dict_password(result)
if isinstance(result, dict) else result)
return result

View File

@ -0,0 +1,77 @@
# 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 socket
from oslo_config import cfg
from oslo_service import service
from oslo_service import wsgi
from ironic.common import utils
CONF = cfg.CONF
class WSGIService(service.ServiceBase):
def __init__(self, name, app, conf):
"""Initialize, but do not start the WSGI server.
:param name: The name of the WSGI server given