diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 998f29a7e7..62724b44f2 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -977,9 +977,141 @@ # value) #allowed_direct_url_schemes = -# The secret token given to Swift to allow temporary URL -# downloads. Required for temporary URLs. (string value) -#swift_temp_url_key = +# Authentication URL (string value) +#auth_url = + +# Authentication strategy to use when connecting to glance. +# (string value) +# Allowed values: keystone, noauth +#auth_strategy = keystone + +# Authentication type to load (string value) +# Deprecated group/name - [glance]/auth_plugin +#auth_type = + +# PEM encoded Certificate Authority to use when verifying +# HTTPs connections. (string value) +#cafile = + +# PEM encoded client certificate cert file (string value) +#certfile = + +# Optional domain ID to use with v3 and v2 parameters. It will +# be used for both the user and project domain in v3 and +# ignored in v2 authentication. (string value) +#default_domain_id = + +# Optional domain name to use with v3 API and v2 parameters. +# It will be used for both the user and project domain in v3 +# and ignored in v2 authentication. (string value) +#default_domain_name = + +# Domain ID to scope to (string value) +#domain_id = + +# Domain name to scope to (string value) +#domain_name = + +# Allow to perform insecure SSL (https) requests to glance. +# (boolean value) +#glance_api_insecure = false + +# A list of the glance api servers available to ironic. Prefix +# with https:// for SSL-based glance API servers. Format is +# [hostname|IP]:port. (list value) +#glance_api_servers = + +# Optional path to a CA certificate bundle to be used to +# validate the SSL certificate served by glance. It is used +# when glance_api_insecure is set to False. (string value) +#glance_cafile = + +# Default glance hostname or IP address. (string value) +#glance_host = $my_ip + +# Number of retries when downloading an image from glance. +# (integer value) +#glance_num_retries = 0 + +# Default glance port. (port value) +# Minimum value: 0 +# Maximum value: 65535 +#glance_port = 9292 + +# Default protocol to use when connecting to glance. Set to +# https for SSL. (string value) +# Allowed values: http, https +#glance_protocol = http + +# Verify HTTPS connections. (boolean value) +#insecure = false + +# PEM encoded client certificate key file (string value) +#keyfile = + +# User's password (string value) +#password = + +# Domain ID containing project (string value) +#project_domain_id = + +# Domain name containing project (string value) +#project_domain_name = + +# Project ID to scope to (string value) +# Deprecated group/name - [glance]/tenant-id +#project_id = + +# Project name to scope to (string value) +# Deprecated group/name - [glance]/tenant-name +#project_name = + +# The account that Glance uses to communicate with Swift. The +# format is "AUTH_uuid". "uuid" is the UUID for the account +# configured in the glance-api.conf. Required for temporary +# URLs when Glance backend is Swift. For example: +# "AUTH_a422b2-91f3-2f46-74b7-d7c9e8958f5d30". Swift temporary +# URL format: +# "endpoint_url/api_version/[account/]container/object_id" +# (string value) +#swift_account = + +# The Swift API version to create a temporary URL for. +# Defaults to "v1". Swift temporary URL format: +# "endpoint_url/api_version/[account/]container/object_id" +# (string value) +#swift_api_version = v1 + +# The Swift container Glance is configured to store its images +# in. Defaults to "glance", which is the default in glance- +# api.conf. Swift temporary URL format: +# "endpoint_url/api_version/[account/]container/object_id" +# (string value) +#swift_container = glance + +# The "endpoint" (scheme, hostname, optional port) for the +# Swift URL of the form +# "endpoint_url/api_version/[account/]container/object_id". Do +# not include trailing "/". For example, use +# "https://swift.example.com". If using RADOS Gateway, +# endpoint may also contain /swift path; if it does not, it +# will be appended. Required for temporary URLs. (string +# value) +#swift_endpoint_url = + +# This should match a config by the same name in the Glance +# configuration file. When set to 0, a single-tenant store +# will only use one container to store all images. When set to +# an integer value between 1 and 32, a single-tenant store +# will use multiple containers to store images, and this value +# will determine how many containers are created. (integer +# value) +#swift_store_multiple_containers_seed = 0 + +# Whether to cache generated Swift temporary URLs. Setting it +# to true is only useful when an image caching proxy is used. +# Defaults to False. (boolean value) +#swift_temp_url_cache_enabled = false # The length of time in seconds that the temporary URL will be # valid for. Defaults to 20 minutes. If some deploys get a 401 @@ -989,11 +1121,6 @@ # swift_temp_url_expected_download_start_delay (integer value) #swift_temp_url_duration = 1200 -# Whether to cache generated Swift temporary URLs. Setting it -# to true is only useful when an image caching proxy is used. -# Defaults to False. (boolean value) -#swift_temp_url_cache_enabled = false - # This is the delay (in seconds) from the time of the deploy # request (when the Swift temporary URL is generated) to when # the IPA ramdisk starts up and URL is used for the image @@ -1007,47 +1134,9 @@ # Minimum value: 0 #swift_temp_url_expected_download_start_delay = 0 -# The "endpoint" (scheme, hostname, optional port) for the -# Swift URL of the form -# "endpoint_url/api_version/[account/]container/object_id". Do -# not include trailing "/". For example, use -# "https://swift.example.com". If using RADOS Gateway, -# endpoint may also contain /swift path; if it does not, it -# will be appended. Required for temporary URLs. (string -# value) -#swift_endpoint_url = - -# The Swift API version to create a temporary URL for. -# Defaults to "v1". Swift temporary URL format: -# "endpoint_url/api_version/[account/]container/object_id" -# (string value) -#swift_api_version = v1 - -# The account that Glance uses to communicate with Swift. The -# format is "AUTH_uuid". "uuid" is the UUID for the account -# configured in the glance-api.conf. Required for temporary -# URLs when Glance backend is Swift. For example: -# "AUTH_a422b2-91f3-2f46-74b7-d7c9e8958f5d30". Swift temporary -# URL format: -# "endpoint_url/api_version/[account/]container/object_id" -# (string value) -#swift_account = - -# The Swift container Glance is configured to store its images -# in. Defaults to "glance", which is the default in glance- -# api.conf. Swift temporary URL format: -# "endpoint_url/api_version/[account/]container/object_id" -# (string value) -#swift_container = glance - -# This should match a config by the same name in the Glance -# configuration file. When set to 0, a single-tenant store -# will only use one container to store all images. When set to -# an integer value between 1 and 32, a single-tenant store -# will use multiple containers to store images, and this value -# will determine how many containers are created. (integer -# value) -#swift_store_multiple_containers_seed = 0 +# The secret token given to Swift to allow temporary URL +# downloads. Required for temporary URLs. (string value) +#swift_temp_url_key = # Type of endpoint to use for temporary URLs. If the Glance # backend is Swift, use "swift"; if it is CEPH with RADOS @@ -1055,41 +1144,30 @@ # Allowed values: swift, radosgw #temp_url_endpoint_type = swift -# Default glance hostname or IP address. (string value) -#glance_host = $my_ip +# Tenant ID (string value) +#tenant_id = -# Default glance port. (port value) -# Minimum value: 0 -# Maximum value: 65535 -#glance_port = 9292 +# Tenant Name (string value) +#tenant_name = -# Default protocol to use when connecting to glance. Set to -# https for SSL. (string value) -# Allowed values: http, https -#glance_protocol = http +# Timeout value for http requests (integer value) +#timeout = -# A list of the glance api servers available to ironic. Prefix -# with https:// for SSL-based glance API servers. Format is -# [hostname|IP]:port. (list value) -#glance_api_servers = +# Trust ID (string value) +#trust_id = -# Allow to perform insecure SSL (https) requests to glance. -# (boolean value) -#glance_api_insecure = false +# User's domain id (string value) +#user_domain_id = -# Number of retries when downloading an image from glance. -# (integer value) -#glance_num_retries = 0 +# User's domain name (string value) +#user_domain_name = -# Authentication strategy to use when connecting to glance. -# (string value) -# Allowed values: keystone, noauth -#auth_strategy = keystone +# User id (string value) +#user_id = -# Optional path to a CA certificate bundle to be used to -# validate the SSL certificate served by glance. It is used -# when glance_api_insecure is set to False. (string value) -#glance_cafile = +# Username (string value) +# Deprecated group/name - [glance]/user-name +#username = [iboot] @@ -1189,10 +1267,63 @@ # From ironic # +# Authentication URL (string value) +#auth_url = + +# Authentication type to load (string value) +# Deprecated group/name - [inspector]/auth_plugin +#auth_type = + +# PEM encoded Certificate Authority to use when verifying +# HTTPs connections. (string value) +#cafile = + +# PEM encoded client certificate cert file (string value) +#certfile = + +# Optional domain ID to use with v3 and v2 parameters. It will +# be used for both the user and project domain in v3 and +# ignored in v2 authentication. (string value) +#default_domain_id = + +# Optional domain name to use with v3 API and v2 parameters. +# It will be used for both the user and project domain in v3 +# and ignored in v2 authentication. (string value) +#default_domain_name = + +# Domain ID to scope to (string value) +#domain_id = + +# Domain name to scope to (string value) +#domain_name = + # whether to enable inspection using ironic-inspector (boolean # value) #enabled = false +# Verify HTTPS connections. (boolean value) +#insecure = false + +# PEM encoded client certificate key file (string value) +#keyfile = + +# User's password (string value) +#password = + +# Domain ID containing project (string value) +#project_domain_id = + +# Domain name containing project (string value) +#project_domain_name = + +# Project ID to scope to (string value) +# Deprecated group/name - [inspector]/tenant-id +#project_id = + +# Project name to scope to (string value) +# Deprecated group/name - [inspector]/tenant-name +#project_name = + # ironic-inspector HTTP endpoint. If this is not set, the # ironic-inspector client default (http://127.0.0.1:5050) will # be used. (string value) @@ -1202,6 +1333,31 @@ # (integer value) #status_check_period = 60 +# Tenant ID (string value) +#tenant_id = + +# Tenant Name (string value) +#tenant_name = + +# Timeout value for http requests (integer value) +#timeout = + +# Trust ID (string value) +#trust_id = + +# User's domain id (string value) +#user_domain_id = + +# User's domain name (string value) +#user_domain_name = + +# User id (string value) +#user_id = + +# Username (string value) +# Deprecated group/name - [inspector]/user-name +#username = + [ipmi] @@ -1631,21 +1787,8 @@ # From ironic # -# URL for connecting to neutron. (string value) -#url = http://$my_ip:9696 - -# Timeout value for connecting to neutron in seconds. (integer -# value) -#url_timeout = 30 - -# Delay value to wait for Neutron agents to setup sufficient -# DHCP configuration for port. (integer value) -# Minimum value: 0 -#port_setup_delay = 0 - -# Client retries in the case of a failed request. (integer -# value) -#retries = 3 +# Authentication URL (string value) +#auth_url = # Authentication strategy to use when connecting to neutron. # Running neutron in noauth mode (related to but not affected @@ -1654,17 +1797,111 @@ # Allowed values: keystone, noauth #auth_strategy = keystone +# Authentication type to load (string value) +# Deprecated group/name - [neutron]/auth_plugin +#auth_type = + +# PEM encoded Certificate Authority to use when verifying +# HTTPs connections. (string value) +#cafile = + +# PEM encoded client certificate cert file (string value) +#certfile = + # Neutron network UUID for the ramdisk to be booted into for # cleaning nodes. Required for "neutron" network interface. It # is also required if cleaning nodes when using "flat" network # interface or "neutron" DHCP provider. (string value) #cleaning_network_uuid = +# Optional domain ID to use with v3 and v2 parameters. It will +# be used for both the user and project domain in v3 and +# ignored in v2 authentication. (string value) +#default_domain_id = + +# Optional domain name to use with v3 API and v2 parameters. +# It will be used for both the user and project domain in v3 +# and ignored in v2 authentication. (string value) +#default_domain_name = + +# Domain ID to scope to (string value) +#domain_id = + +# Domain name to scope to (string value) +#domain_name = + +# Verify HTTPS connections. (boolean value) +#insecure = false + +# PEM encoded client certificate key file (string value) +#keyfile = + +# User's password (string value) +#password = + +# Delay value to wait for Neutron agents to setup sufficient +# DHCP configuration for port. (integer value) +# Minimum value: 0 +#port_setup_delay = 0 + +# Domain ID containing project (string value) +#project_domain_id = + +# Domain name containing project (string value) +#project_domain_name = + +# Project ID to scope to (string value) +# Deprecated group/name - [neutron]/tenant-id +#project_id = + +# Project name to scope to (string value) +# Deprecated group/name - [neutron]/tenant-name +#project_name = + # Neutron network UUID for the ramdisk to be booted into for # provisioning nodes. Required for "neutron" network # interface. (string value) #provisioning_network_uuid = +# Client retries in the case of a failed request. (integer +# value) +#retries = 3 + +# Tenant ID (string value) +#tenant_id = + +# Tenant Name (string value) +#tenant_name = + +# Timeout value for http requests (integer value) +#timeout = + +# Trust ID (string value) +#trust_id = + +# URL for connecting to neutron. Default value translates to +# 'http://$my_ip:9696' when auth_strategy is 'noauth', and to +# discovery from Keystone catalog when auth_strategy is +# 'keystone'. (string value) +#url = + +# Timeout value for connecting to neutron in seconds. (integer +# value) +#url_timeout = 30 + +# User's domain id (string value) +#user_domain_id = + +# User's domain name (string value) +#user_domain_name = + +# User id (string value) +#user_id = + +# Username (string value) +# Deprecated group/name - [neutron]/user-name +#username = + [oneview] @@ -2213,6 +2450,91 @@ #action_timeout = 10 +[service_catalog] + +# +# From ironic +# + +# Authentication URL (string value) +#auth_url = + +# Authentication type to load (string value) +# Deprecated group/name - [service_catalog]/auth_plugin +#auth_type = + +# PEM encoded Certificate Authority to use when verifying +# HTTPs connections. (string value) +#cafile = + +# PEM encoded client certificate cert file (string value) +#certfile = + +# Optional domain ID to use with v3 and v2 parameters. It will +# be used for both the user and project domain in v3 and +# ignored in v2 authentication. (string value) +#default_domain_id = + +# Optional domain name to use with v3 API and v2 parameters. +# It will be used for both the user and project domain in v3 +# and ignored in v2 authentication. (string value) +#default_domain_name = + +# Domain ID to scope to (string value) +#domain_id = + +# Domain name to scope to (string value) +#domain_name = + +# Verify HTTPS connections. (boolean value) +#insecure = false + +# PEM encoded client certificate key file (string value) +#keyfile = + +# User's password (string value) +#password = + +# Domain ID containing project (string value) +#project_domain_id = + +# Domain name containing project (string value) +#project_domain_name = + +# Project ID to scope to (string value) +# Deprecated group/name - [service_catalog]/tenant-id +#project_id = + +# Project name to scope to (string value) +# Deprecated group/name - [service_catalog]/tenant-name +#project_name = + +# Tenant ID (string value) +#tenant_id = + +# Tenant Name (string value) +#tenant_name = + +# Timeout value for http requests (integer value) +#timeout = + +# Trust ID (string value) +#trust_id = + +# User's domain id (string value) +#user_domain_id = + +# User's domain name (string value) +#user_domain_name = + +# User id (string value) +#user_id = + +# Username (string value) +# Deprecated group/name - [service_catalog]/user-name +#username = + + [snmp] # @@ -2285,10 +2607,88 @@ # From ironic # +# Authentication URL (string value) +#auth_url = + +# Authentication type to load (string value) +# Deprecated group/name - [swift]/auth_plugin +#auth_type = + +# PEM encoded Certificate Authority to use when verifying +# HTTPs connections. (string value) +#cafile = + +# PEM encoded client certificate cert file (string value) +#certfile = + +# Optional domain ID to use with v3 and v2 parameters. It will +# be used for both the user and project domain in v3 and +# ignored in v2 authentication. (string value) +#default_domain_id = + +# Optional domain name to use with v3 API and v2 parameters. +# It will be used for both the user and project domain in v3 +# and ignored in v2 authentication. (string value) +#default_domain_name = + +# Domain ID to scope to (string value) +#domain_id = + +# Domain name to scope to (string value) +#domain_name = + +# Verify HTTPS connections. (boolean value) +#insecure = false + +# PEM encoded client certificate key file (string value) +#keyfile = + +# User's password (string value) +#password = + +# Domain ID containing project (string value) +#project_domain_id = + +# Domain name containing project (string value) +#project_domain_name = + +# Project ID to scope to (string value) +# Deprecated group/name - [swift]/tenant-id +#project_id = + +# Project name to scope to (string value) +# Deprecated group/name - [swift]/tenant-name +#project_name = + # Maximum number of times to retry a Swift request, before # failing. (integer value) #swift_max_retries = 2 +# Tenant ID (string value) +#tenant_id = + +# Tenant Name (string value) +#tenant_name = + +# Timeout value for http requests (integer value) +#timeout = + +# Trust ID (string value) +#trust_id = + +# User's domain id (string value) +#user_domain_id = + +# User's domain name (string value) +#user_domain_name = + +# User id (string value) +#user_id = + +# Username (string value) +# Deprecated group/name - [swift]/user-name +#username = + [virtualbox] diff --git a/ironic/cmd/conductor.py b/ironic/cmd/conductor.py index 39718e5021..794b52ce38 100644 --- a/ironic/cmd/conductor.py +++ b/ironic/cmd/conductor.py @@ -22,12 +22,40 @@ The Ironic Management Service import sys from oslo_config import cfg +from oslo_log import log from oslo_service import service +from ironic.common.i18n import _LW from ironic.common import service as ironic_service +from ironic.conf import auth CONF = cfg.CONF +LOG = log.getLogger(__name__) + +SECTIONS_WITH_AUTH = ( + 'service_catalog', 'neutron', 'glance', 'swift', 'inspector') + + +# TODO(pas-ha) remove this check after deprecation period +def _check_auth_options(conf): + missing = [] + for section in SECTIONS_WITH_AUTH: + if not auth.load_auth(conf, section): + missing.append('[%s]' % section) + if missing: + link = "http://docs.openstack.org/releasenotes/ironic/newton.html" + LOG.warning(_LW("Failed to load authentification credentials from " + "%(missing)s config sections. " + "The corresponding service users' credentials " + "will be loaded from [%(old)s] config section, " + "which is deprecated for this purpose. " + "Please update the config file. " + "For more info see %(link)s."), + dict(missing=", ".join(missing), + old=auth.LEGACY_SECTION, + link=link)) + def main(): # Parse config file and command line options, then start logging @@ -37,6 +65,8 @@ def main(): 'ironic.conductor.manager', 'ConductorManager') + _check_auth_options(CONF) + launcher = service.launch(CONF, mgr) launcher.wait() diff --git a/ironic/common/image_service.py b/ironic/common/image_service.py index 4e219715e0..6016ac11e9 100644 --- a/ironic/common/image_service.py +++ b/ironic/common/image_service.py @@ -35,9 +35,14 @@ from ironic.conf import CONF IMAGE_CHUNK_SIZE = 1024 * 1024 # 1mb -# TODO(rama_y): This import should be removed, -# once https://review.openstack.org/#/c/309070 is merged. -CONF.import_opt('my_ip', 'ironic.netconf') +_GLANCE_SESSION = None + + +def _get_glance_session(): + global _GLANCE_SESSION + if not _GLANCE_SESSION: + _GLANCE_SESSION = keystone.get_session('glance') + return _GLANCE_SESSION def import_versioned_module(version, submodule=None): @@ -52,7 +57,8 @@ def GlanceImageService(client=None, version=1, context=None): service_class = getattr(module, 'GlanceImageService') if (context is not None and CONF.glance.auth_strategy == 'keystone' and not context.auth_token): - context.auth_token = keystone.get_admin_auth_token() + session = _get_glance_session() + context.auth_token = keystone.get_admin_auth_token(session) return service_class(client, version, context) diff --git a/ironic/common/keystone.py b/ironic/common/keystone.py index 8f62123b3c..9d79ab5db4 100644 --- a/ironic/common/keystone.py +++ b/ironic/common/keystone.py @@ -12,132 +12,125 @@ # License for the specific language governing permissions and limitations # under the License. -from keystoneclient import exceptions as ksexception -from oslo_concurrency import lockutils -from six.moves.urllib import parse +"""Central place for handling Keystone authorization and service lookup.""" + +from keystoneauth1 import exceptions as kaexception +from keystoneauth1 import loading as kaloading +from oslo_log import log as logging +import six +from six.moves.urllib import parse # for legacy options loading only from ironic.common import exception from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.conf import auth as ironic_auth from ironic.conf import CONF -CONF.import_group('keystone_authtoken', 'keystonemiddleware.auth_token') -_KS_CLIENT = None +LOG = logging.getLogger(__name__) +# FIXME(pas-ha): for backward compat with legacy options loading only def _is_apiv3(auth_url, auth_version): - """Checks if V3 version of API is being used or not. + """Check if V3 version of API is being used or not. This method inspects auth_url and auth_version, and checks whether V3 version of the API is being used or not. - + When no auth_version is specified and auth_url is not a versioned + endpoint, v2.0 is assumed. :param auth_url: a http or https url to be inspected (like 'http://127.0.0.1:9898/'). :param auth_version: a string containing the version (like 'v2', 'v3.0') + or None :returns: True if V3 of the API is being used. """ return auth_version == 'v3.0' or '/v3' in parse.urlparse(auth_url).path -def _get_ksclient(token=None): - auth_url = CONF.keystone_authtoken.auth_uri - if not auth_url: - raise exception.KeystoneFailure(_('Keystone API endpoint is missing')) - - auth_version = CONF.keystone_authtoken.auth_version - api_v3 = _is_apiv3(auth_url, auth_version) - - if api_v3: - from keystoneclient.v3 import client - else: - from keystoneclient.v2_0 import client - - auth_url = get_keystone_url(auth_url, auth_version) - try: - if token: - return client.Client(token=token, auth_url=auth_url) - else: - params = {'username': CONF.keystone_authtoken.admin_user, - 'password': CONF.keystone_authtoken.admin_password, - 'tenant_name': CONF.keystone_authtoken.admin_tenant_name, - 'region_name': CONF.keystone.region_name, - 'auth_url': auth_url} - return _get_ksclient_from_conf(client, **params) - except ksexception.Unauthorized: - raise exception.KeystoneUnauthorized() - except ksexception.AuthorizationFailure as err: - raise exception.KeystoneFailure(_('Could not authorize in Keystone:' - ' %s') % err) +def ks_exceptions(f): + """Wraps keystoneclient functions and centralizes exception handling.""" + @six.wraps(f) + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except kaexception.EndpointNotFound: + service_type = kwargs.get('service_type', 'baremetal') + endpoint_type = kwargs.get('endpoint_type', 'internal') + raise exception.CatalogNotFound( + service_type=service_type, endpoint_type=endpoint_type) + except (kaexception.Unauthorized, kaexception.AuthorizationFailure): + raise exception.KeystoneUnauthorized() + except (kaexception.NoMatchingPlugin, + kaexception.MissingRequiredOptions) as e: + raise exception.ConfigInvalid(six.text_type(e)) + except Exception as e: + LOG.exception(_LE('Keystone request failed: %(msg)s'), + {'msg': six.text_type(e)}) + raise exception.KeystoneFailure(six.text_type(e)) + return wrapper -@lockutils.synchronized('keystone_client', 'ironic-') -def _get_ksclient_from_conf(client, **params): - global _KS_CLIENT - # NOTE(yuriyz): use Keystone client default gap, to determine whether the - # given token is about to expire - if _KS_CLIENT is None or _KS_CLIENT.auth_ref.will_expire_soon(): - _KS_CLIENT = client.Client(**params) - return _KS_CLIENT +@ks_exceptions +def get_session(group): + auth = ironic_auth.load_auth(CONF, group) or _get_legacy_auth() + if not auth: + msg = _("Failed to load auth from either [%(new)s] or [%(old)s] " + "config sections.") + raise exception.ConfigInvalid(message=msg, new=group, + old=ironic_auth.LEGACY_SECTION) + session = kaloading.load_session_from_conf_options( + CONF, group, auth=auth) + return session -def get_keystone_url(auth_url, auth_version): - """Gives an http/https url to contact keystone. +# FIXME(pas-ha) remove legacy path after deprecation +def _get_legacy_auth(): + """Load auth from keystone_authtoken config section - Given an auth_url and auth_version, this method generates the url in - which keystone can be reached. - - :param auth_url: a http or https url to be inspected (like - 'http://127.0.0.1:9898/'). - :param auth_version: a string containing the version (like v2, v3.0, etc) - :returns: a string containing the keystone url + Used only to provide backward compatibility with old configs. """ - api_v3 = _is_apiv3(auth_url, auth_version) - api_version = 'v3' if api_v3 else 'v2.0' - # NOTE(lucasagomes): Get rid of the trailing '/' otherwise urljoin() - # fails to override the version in the URL - return parse.urljoin(auth_url.rstrip('/'), api_version) + conf = getattr(CONF, ironic_auth.LEGACY_SECTION) + legacy_loader = kaloading.get_plugin_loader('password') + auth_params = { + 'auth_url': conf.auth_uri, + 'username': conf.admin_user, + 'password': conf.admin_password, + 'tenant_name': conf.admin_tenant_name + } + api_v3 = _is_apiv3(conf.auth_uri, conf.auth_version) + if api_v3: + # NOTE(pas-ha): mimic defaults of keystoneclient + auth_params.update({ + 'project_domain_id': 'default', + 'user_domain_id': 'default', + }) + return legacy_loader.load_from_options(**auth_params) -def get_service_url(service_type='baremetal', endpoint_type='internal'): +@ks_exceptions +def get_service_url(session, service_type='baremetal', + endpoint_type='internal'): """Wrapper for get service url from keystone service catalog. - Given a service_type and an endpoint_type, this method queries keystone - service catalog and provides the url for the desired endpoint. + Given a service_type and an endpoint_type, this method queries + keystone service catalog and provides the url for the desired + endpoint. :param service_type: the keystone service for which url is required. :param endpoint_type: the type of endpoint for the service. :returns: an http/https url for the desired endpoint. """ - ksclient = _get_ksclient() - - if not ksclient.has_service_catalog(): - raise exception.KeystoneFailure(_('No Keystone service catalog ' - 'loaded')) - - try: - endpoint = ksclient.service_catalog.url_for( - service_type=service_type, - endpoint_type=endpoint_type, - region_name=CONF.keystone.region_name) - - except ksexception.EndpointNotFound: - raise exception.CatalogNotFound(service_type=service_type, - endpoint_type=endpoint_type) - - return endpoint + return session.get_endpoint(service_type=service_type, + interface_type=endpoint_type, + region=CONF.keystone.region_name) -def get_admin_auth_token(): - """Get an admin auth_token from the Keystone.""" - ksclient = _get_ksclient() - return ksclient.auth_token +@ks_exceptions +def get_admin_auth_token(session): + """Get admin token. - -def token_expires_soon(token, duration=None): - """Determines if token expiration is about to occur. - - :param duration: time interval in seconds - :returns: boolean : true if expiration is within the given duration + Currently used for inspector, glance and swift clients. + Only swift client does not actually support using sessions directly, + LP #1518938, others will be updated in ironic code. """ - ksclient = _get_ksclient(token=token) - return ksclient.auth_ref.will_expire_soon(stale_duration=duration) + return session.get_token() diff --git a/ironic/common/neutron.py b/ironic/common/neutron.py index b79e96d3f5..8d2ac6a9b4 100644 --- a/ironic/common/neutron.py +++ b/ironic/common/neutron.py @@ -24,29 +24,49 @@ from ironic.conf import CONF LOG = log.getLogger(__name__) +DEFAULT_NEUTRON_URL = 'http://%s:9696' % CONF.my_ip + +_NEUTRON_SESSION = None + + +def _get_neutron_session(): + global _NEUTRON_SESSION + if not _NEUTRON_SESSION: + _NEUTRON_SESSION = keystone.get_session('neutron') + return _NEUTRON_SESSION + def get_client(token=None): - params = { - 'timeout': CONF.neutron.url_timeout, - 'retries': CONF.neutron.retries, - 'insecure': CONF.keystone_authtoken.insecure, - 'ca_cert': CONF.keystone_authtoken.certfile, - } - + params = {'retries': CONF.neutron.retries} + url = CONF.neutron.url if CONF.neutron.auth_strategy == 'noauth': - params['endpoint_url'] = CONF.neutron.url + params['endpoint_url'] = url or DEFAULT_NEUTRON_URL params['auth_strategy'] = 'noauth' + params.update({ + 'timeout': CONF.neutron.url_timeout or CONF.neutron.timeout, + 'insecure': CONF.neutron.insecure, + 'ca_cert': CONF.neutron.cafile}) else: - params['endpoint_url'] = ( - CONF.neutron.url or - keystone.get_service_url(service_type='network')) - params['username'] = CONF.keystone_authtoken.admin_user - params['tenant_name'] = CONF.keystone_authtoken.admin_tenant_name - params['password'] = CONF.keystone_authtoken.admin_password - params['auth_url'] = (CONF.keystone_authtoken.auth_uri or '') - if CONF.keystone.region_name: - params['region_name'] = CONF.keystone.region_name - params['token'] = token + session = _get_neutron_session() + if token is None: + params['session'] = session + # NOTE(pas-ha) endpoint_override==None will auto-discover + # endpoint from Keystone catalog. + # Region is needed only in this case. + # SSL related options are ignored as they are already embedded + # in keystoneauth Session object + if url: + params['endpoint_override'] = url + else: + params['region_name'] = CONF.keystone.region_name + else: + params['token'] = token + params['endpoint_url'] = url or keystone.get_service_url( + session, service_type='network') + params.update({ + 'timeout': CONF.neutron.url_timeout or CONF.neutron.timeout, + 'insecure': CONF.neutron.insecure, + 'ca_cert': CONF.neutron.cafile}) return clientv20.Client(**params) diff --git a/ironic/common/service.py b/ironic/common/service.py index e2d4d3c456..a64a2a3d06 100644 --- a/ironic/common/service.py +++ b/ironic/common/service.py @@ -108,7 +108,6 @@ def prepare_service(argv=None): 'qpid.messaging=INFO', 'oslo_messaging=INFO', 'sqlalchemy=WARNING', - 'keystoneclient=INFO', 'stevedore=INFO', 'eventlet.wsgi.server=INFO', 'iso8601=WARNING', diff --git a/ironic/common/swift.py b/ironic/common/swift.py index 5362571a91..c16cb3c772 100644 --- a/ironic/common/swift.py +++ b/ironic/common/swift.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +import six from six.moves import http_client from six.moves.urllib import parse from swiftclient import client as swift_client @@ -25,60 +26,39 @@ from ironic.common.i18n import _ from ironic.common import keystone from ironic.conf import CONF -CONF.import_opt('admin_user', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('admin_tenant_name', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('admin_password', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('auth_uri', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('auth_version', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('insecure', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('cafile', 'keystonemiddleware.auth_token', - group='keystone_authtoken') -CONF.import_opt('region_name', 'keystonemiddleware.auth_token', - group='keystone_authtoken') + +_SWIFT_SESSION = None + + +def _get_swift_session(): + global _SWIFT_SESSION + if not _SWIFT_SESSION: + _SWIFT_SESSION = keystone.get_session('swift') + return _SWIFT_SESSION class SwiftAPI(object): """API for communicating with Swift.""" - def __init__(self, - user=None, - tenant_name=None, - key=None, - auth_url=None, - auth_version=None, - region_name=None): - """Constructor for creating a SwiftAPI object. - - :param user: the name of the user for Swift account - :param tenant_name: the name of the tenant for Swift account - :param key: the 'password' or key to authenticate with - :param auth_url: the url for authentication - :param auth_version: the version of api to use for authentication - :param region_name: the region used for getting endpoints of swift - """ - user = user or CONF.keystone_authtoken.admin_user - tenant_name = tenant_name or CONF.keystone_authtoken.admin_tenant_name - key = key or CONF.keystone_authtoken.admin_password - auth_url = auth_url or CONF.keystone_authtoken.auth_uri - auth_version = auth_version or CONF.keystone_authtoken.auth_version - auth_url = keystone.get_keystone_url(auth_url, auth_version) - params = {'retries': CONF.swift.swift_max_retries, - 'insecure': CONF.keystone_authtoken.insecure, - 'cacert': CONF.keystone_authtoken.cafile, - 'user': user, - 'tenant_name': tenant_name, - 'key': key, - 'authurl': auth_url, - 'auth_version': auth_version} - region_name = region_name or CONF.keystone_authtoken.region_name - if region_name: - params['os_options'] = {'region_name': region_name} + def __init__(self): + # TODO(pas-ha): swiftclient does not support keystone sessions ATM. + # Must be reworked when LP bug #1518938 is fixed. + session = _get_swift_session() + params = { + 'retries': CONF.swift.swift_max_retries, + 'preauthurl': keystone.get_service_url( + session, + service_type='object-store'), + 'preauthtoken': keystone.get_admin_auth_token(session) + } + # NOTE(pas-ha):session.verify is for HTTPS urls and can be + # - False (do not verify) + # - True (verify but try to locate system CA certificates) + # - Path (verify using specific CA certificate) + verify = session.verify + params['insecure'] = not verify + if verify and isinstance(verify, six.string_types): + params['cacert'] = verify self.connection = swift_client.Connection(**params) @@ -131,8 +111,7 @@ class SwiftAPI(object): raise exception.SwiftOperationError(operation=operation, error=e) - storage_url, token = self.connection.get_auth() - parse_result = parse.urlparse(storage_url) + parse_result = parse.urlparse(self.connection.url) swift_object_path = '/'.join((parse_result.path, container, object)) temp_url_key = account_info['x-account-meta-temp-url-key'] url_path = swift_utils.generate_temp_url(swift_object_path, timeout, diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index 1ec5ee33c8..048343c64e 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -38,6 +38,7 @@ from ironic.conf import metrics_statsd from ironic.conf import neutron from ironic.conf import oneview from ironic.conf import seamicro +from ironic.conf import service_catalog from ironic.conf import snmp from ironic.conf import ssh from ironic.conf import swift @@ -68,6 +69,7 @@ metrics_statsd.register_opts(CONF) neutron.register_opts(CONF) oneview.register_opts(CONF) seamicro.register_opts(CONF) +service_catalog.register_opts(CONF) snmp.register_opts(CONF) ssh.register_opts(CONF) swift.register_opts(CONF) diff --git a/ironic/conf/auth.py b/ironic/conf/auth.py new file mode 100644 index 0000000000..26dcdac1e2 --- /dev/null +++ b/ironic/conf/auth.py @@ -0,0 +1,79 @@ +# Copyright 2016 Mirantis Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy + +from keystoneauth1 import exceptions as kaexception +from keystoneauth1 import loading as kaloading +from oslo_config import cfg + + +LEGACY_SECTION = 'keystone_authtoken' +OLD_SESSION_OPTS = { + 'certfile': [cfg.DeprecatedOpt('certfile', LEGACY_SECTION)], + 'keyfile': [cfg.DeprecatedOpt('keyfile', LEGACY_SECTION)], + 'cafile': [cfg.DeprecatedOpt('cafile', LEGACY_SECTION)], + 'insecure': [cfg.DeprecatedOpt('insecure', LEGACY_SECTION)], + 'timeout': [cfg.DeprecatedOpt('timeout', LEGACY_SECTION)], +} + +# FIXME(pas-ha) remove import of auth_token section after deprecation period +cfg.CONF.import_group(LEGACY_SECTION, 'keystonemiddleware.auth_token') + + +def load_auth(conf, group): + try: + auth = kaloading.load_auth_from_conf_options(conf, group) + except kaexception.MissingRequiredOptions: + auth = None + return auth + + +def register_auth_opts(conf, group): + """Register session- and auth-related options + + Registers only basic auth options shared by all auth plugins. + The rest are registered at runtime depending on auth plugin used. + """ + kaloading.register_session_conf_options( + conf, group, deprecated_opts=OLD_SESSION_OPTS) + kaloading.register_auth_conf_options(conf, group) + + +def add_auth_opts(options): + """Add auth options to sample config + + As these are dynamically registered at runtime, + this adds options for most used auth_plugins + when generating sample config. + """ + def add_options(opts, opts_to_add): + for new_opt in opts_to_add: + for opt in opts: + if opt.name == new_opt.name: + break + else: + opts.append(new_opt) + + opts = copy.deepcopy(options) + opts.insert(0, kaloading.get_auth_common_conf_options()[0]) + # NOTE(dims): There are a lot of auth plugins, we just generate + # the config options for a few common ones + plugins = ['password', 'v2password', 'v3password'] + for name in plugins: + plugin = kaloading.get_plugin_loader(name) + add_options(opts, kaloading.get_auth_plugin_conf_options(plugin)) + add_options(opts, kaloading.get_session_conf_options()) + opts.sort(key=lambda x: x.name) + return opts diff --git a/ironic/conf/glance.py b/ironic/conf/glance.py index a6312de4af..9c46a81811 100644 --- a/ironic/conf/glance.py +++ b/ironic/conf/glance.py @@ -18,6 +18,7 @@ from oslo_config import cfg from ironic.common.i18n import _ +from ironic.conf import auth opts = [ cfg.ListOpt('allowed_direct_url_schemes', @@ -145,3 +146,8 @@ opts = [ def register_opts(conf): conf.register_opts(opts, group='glance') + auth.register_auth_opts(conf, 'glance') + + +def list_opts(): + return auth.add_auth_opts(opts) diff --git a/ironic/conf/inspector.py b/ironic/conf/inspector.py index 05eeb75332..50613e9f03 100644 --- a/ironic/conf/inspector.py +++ b/ironic/conf/inspector.py @@ -15,6 +15,7 @@ from oslo_config import cfg from ironic.common.i18n import _ +from ironic.conf import auth opts = [ cfg.BoolOpt('enabled', default=False, @@ -31,3 +32,8 @@ opts = [ def register_opts(conf): conf.register_opts(opts, group='inspector') + auth.register_auth_opts(conf, 'inspector') + + +def list_opts(): + return auth.add_auth_opts(opts) diff --git a/ironic/conf/neutron.py b/ironic/conf/neutron.py index 03869d5945..4e02f4725d 100644 --- a/ironic/conf/neutron.py +++ b/ironic/conf/neutron.py @@ -17,11 +17,15 @@ from oslo_config import cfg from ironic.common.i18n import _ +from ironic.conf import auth opts = [ cfg.StrOpt('url', - default='http://$my_ip:9696', - help=_('URL for connecting to neutron.')), + help=_("URL for connecting to neutron. " + "Default value translates to 'http://$my_ip:9696' " + "when auth_strategy is 'noauth', " + "and to discovery from Keystone catalog " + "when auth_strategy is 'keystone'.")), cfg.IntOpt('url_timeout', default=30, help=_('Timeout value for connecting to neutron in seconds.')), @@ -55,3 +59,8 @@ opts = [ def register_opts(conf): conf.register_opts(opts, group='neutron') + auth.register_auth_opts(conf, 'neutron') + + +def list_opts(): + return auth.add_auth_opts(opts) diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index 18d608791c..6e7a258ddf 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -45,25 +45,26 @@ _opts = [ ('database', ironic.conf.database.opts), ('deploy', ironic.conf.deploy.opts), ('dhcp', ironic.conf.dhcp.opts), - ('glance', ironic.conf.glance.opts), + ('glance', ironic.conf.glance.list_opts()), ('iboot', ironic.conf.iboot.opts), ('ilo', ironic.conf.ilo.opts), - ('inspector', ironic.conf.inspector.opts), + ('inspector', ironic.conf.inspector.list_opts()), ('ipmi', ironic.conf.ipmi.opts), ('irmc', ironic.conf.irmc.opts), ('iscsi', ironic.drivers.modules.iscsi_deploy.iscsi_opts), ('keystone', ironic.conf.keystone.opts), - ('neutron', ironic.conf.neutron.opts), ('metrics', ironic.conf.metrics.opts), ('metrics_statsd', ironic.conf.metrics_statsd.opts), + ('neutron', ironic.conf.neutron.list_opts()), ('oneview', ironic.conf.oneview.opts), ('pxe', itertools.chain( ironic.drivers.modules.iscsi_deploy.pxe_opts, ironic.drivers.modules.pxe.pxe_opts)), ('seamicro', ironic.conf.seamicro.opts), + ('service_catalog', ironic.conf.service_catalog.list_opts()), ('snmp', ironic.conf.snmp.opts), ('ssh', ironic.conf.ssh.opts), - ('swift', ironic.conf.swift.opts), + ('swift', ironic.conf.swift.list_opts()), ('virtualbox', ironic.conf.virtualbox.opts), ] diff --git a/ironic/conf/service_catalog.py b/ironic/conf/service_catalog.py new file mode 100644 index 0000000000..610d20e1dd --- /dev/null +++ b/ironic/conf/service_catalog.py @@ -0,0 +1,33 @@ +# Copyright 2016 Mirantis 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. + +from oslo_config import cfg + +from ironic.common.i18n import _ +from ironic.conf import auth + +SERVCIE_CATALOG_GROUP = cfg.OptGroup( + 'service_catalog', + title='Access info for Ironic service user', + help=_('Holds credentials and session options to access ' + 'Keystone catalog for Ironic API endpoint resolution.')) + + +def register_opts(conf): + auth.register_auth_opts(conf, SERVCIE_CATALOG_GROUP.name) + + +def list_opts(): + return auth.add_auth_opts([]) diff --git a/ironic/conf/swift.py b/ironic/conf/swift.py index 66ba9daf3c..66a0b1f5c9 100644 --- a/ironic/conf/swift.py +++ b/ironic/conf/swift.py @@ -17,6 +17,7 @@ from oslo_config import cfg from ironic.common.i18n import _ +from ironic.conf import auth opts = [ cfg.IntOpt('swift_max_retries', @@ -28,3 +29,8 @@ opts = [ def register_opts(conf): conf.register_opts(opts, group='swift') + auth.register_auth_opts(conf, 'swift') + + +def list_opts(): + return auth.add_auth_opts(opts) diff --git a/ironic/drivers/modules/deploy_utils.py b/ironic/drivers/modules/deploy_utils.py index 2d7a65c419..61da5b12af 100644 --- a/ironic/drivers/modules/deploy_utils.py +++ b/ironic/drivers/modules/deploy_utils.py @@ -86,6 +86,38 @@ warn_about_unsafe_shred_parameters() # All functions are called from deploy() directly or indirectly. # They are split for stub-out. +_IRONIC_SESSION = None + + +def _get_ironic_session(): + global _IRONIC_SESSION + if not _IRONIC_SESSION: + _IRONIC_SESSION = keystone.get_session('service_catalog') + return _IRONIC_SESSION + + +def get_ironic_api_url(): + """Resolve Ironic API endpoint + + either from config of from Keystone catalog. + """ + ironic_api = CONF.conductor.api_url + if not ironic_api: + try: + ironic_session = _get_ironic_session() + ironic_api = keystone.get_service_url(ironic_session) + except (exception.KeystoneFailure, + exception.CatalogNotFound, + exception.KeystoneUnauthorized) as e: + raise exception.InvalidParameterValue(_( + "Couldn't get the URL of the Ironic API service from the " + "configuration file or keystone catalog. Keystone error: " + "%s") % six.text_type(e)) + # NOTE: we should strip '/' from the end because it might be used in + # hardcoded ramdisk script + ironic_api = ironic_api.rstrip('/') + return ironic_api + def discovery(portal_address, portal_port): """Do iSCSI discovery on portal.""" @@ -998,10 +1030,8 @@ def build_agent_options(node): :returns: a dictionary containing the parameters to be passed to agent ramdisk. """ - ironic_api = (CONF.conductor.api_url or - keystone.get_service_url()).rstrip('/') agent_config_opts = { - 'ipa-api-url': ironic_api, + 'ipa-api-url': get_ironic_api_url(), 'ipa-driver-name': node.driver, # NOTE: The below entry is a temporary workaround for bug/1433812 'coreos.configdrive': 0, diff --git a/ironic/drivers/modules/inspector.py b/ironic/drivers/modules/inspector.py index 907ad21960..59c5a31575 100644 --- a/ironic/drivers/modules/inspector.py +++ b/ironic/drivers/modules/inspector.py @@ -40,6 +40,15 @@ client = importutils.try_import('ironic_inspector_client') INSPECTOR_API_VERSION = (1, 0) +_INSPECTOR_SESSION = None + + +def _get_inspector_session(): + global _INSPECTOR_SESSION + if not _INSPECTOR_SESSION: + _INSPECTOR_SESSION = keystone.get_session('inspector') + return _INSPECTOR_SESSION + class Inspector(base.InspectInterface): """In-band inspection via ironic-inspector project.""" @@ -165,7 +174,8 @@ def _check_status(task): # NOTE(dtantsur): periodic tasks do not have proper tokens in context if CONF.auth_strategy == 'keystone': - task.context.auth_token = keystone.get_admin_auth_token() + session = _get_inspector_session() + task.context.auth_token = keystone.get_admin_auth_token(session) try: status = _call_inspector(client.get_status, node.uuid, task.context) diff --git a/ironic/drivers/modules/iscsi_deploy.py b/ironic/drivers/modules/iscsi_deploy.py index 3369b2586c..e43afad816 100644 --- a/ironic/drivers/modules/iscsi_deploy.py +++ b/ironic/drivers/modules/iscsi_deploy.py @@ -25,7 +25,6 @@ from six.moves.urllib import parse from ironic.common import dhcp_factory from ironic.common import exception from ironic.common.i18n import _ -from ironic.common import keystone from ironic.common import states from ironic.common import utils from ironic.conductor import task_manager @@ -388,16 +387,8 @@ def validate(task): catalog. :raises: MissingParameterValue if no ports are enrolled for the given node. """ - try: - # TODO(lucasagomes): Validate the format of the URL - CONF.conductor.api_url or keystone.get_service_url() - except (exception.KeystoneFailure, - exception.CatalogNotFound, - exception.KeystoneUnauthorized) as e: - raise exception.InvalidParameterValue(_( - "Couldn't get the URL of the Ironic API service from the " - "configuration file or keystone catalog. Keystone error: %s") % e) - + # TODO(lucasagomes): Validate the format of the URL + deploy_utils.get_ironic_api_url() # Validate the root device hints deploy_utils.parse_root_device_hints(task.node) deploy_utils.parse_instance_info(task.node) diff --git a/ironic/tests/unit/common/test_image_service.py b/ironic/tests/unit/common/test_image_service.py index 079f07afd0..57ac3be832 100644 --- a/ironic/tests/unit/common/test_image_service.py +++ b/ironic/tests/unit/common/test_image_service.py @@ -25,7 +25,6 @@ from six.moves import http_client from ironic.common import exception from ironic.common.glance_service.v1 import image_service as glance_v1_service from ironic.common import image_service -from ironic.common import keystone from ironic.tests import base if six.PY3: @@ -254,56 +253,59 @@ class FileImageServiceTestCase(base.TestCase): class ServiceGetterTestCase(base.TestCase): - @mock.patch.object(keystone, 'get_admin_auth_token', autospec=True) + @mock.patch.object(image_service, '_get_glance_session') @mock.patch.object(glance_v1_service.GlanceImageService, '__init__', return_value=None, autospec=True) - def test_get_glance_image_service(self, glance_service_mock, token_mock): + def test_get_glance_image_service(self, glance_service_mock, + session_mock): image_href = 'image-uuid' self.context.auth_token = 'fake' image_service.get_image_service(image_href, context=self.context) glance_service_mock.assert_called_once_with(mock.ANY, None, 1, self.context) - self.assertFalse(token_mock.called) + self.assertFalse(session_mock.called) - @mock.patch.object(keystone, 'get_admin_auth_token', autospec=True) + @mock.patch.object(image_service, '_get_glance_session') @mock.patch.object(glance_v1_service.GlanceImageService, '__init__', return_value=None, autospec=True) def test_get_glance_image_service_url(self, glance_service_mock, - token_mock): + session_mock): image_href = 'glance://image-uuid' self.context.auth_token = 'fake' image_service.get_image_service(image_href, context=self.context) glance_service_mock.assert_called_once_with(mock.ANY, None, 1, self.context) - self.assertFalse(token_mock.called) + self.assertFalse(session_mock.called) - @mock.patch.object(keystone, 'get_admin_auth_token', autospec=True) + @mock.patch.object(image_service, '_get_glance_session') @mock.patch.object(glance_v1_service.GlanceImageService, '__init__', return_value=None, autospec=True) def test_get_glance_image_service_no_token(self, glance_service_mock, - token_mock): + session_mock): image_href = 'image-uuid' self.context.auth_token = None - token_mock.return_value = 'admin-token' + sess = mock.Mock() + sess.get_token.return_value = 'admin-token' + session_mock.return_value = sess image_service.get_image_service(image_href, context=self.context) glance_service_mock.assert_called_once_with(mock.ANY, None, 1, self.context) - token_mock.assert_called_once_with() + sess.get_token.assert_called_once_with() self.assertEqual('admin-token', self.context.auth_token) - @mock.patch.object(keystone, 'get_admin_auth_token', autospec=True) + @mock.patch.object(image_service, '_get_glance_session') @mock.patch.object(glance_v1_service.GlanceImageService, '__init__', return_value=None, autospec=True) def test_get_glance_image_service_token_not_needed(self, glance_service_mock, - token_mock): + session_mock): image_href = 'image-uuid' self.context.auth_token = None self.config(auth_strategy='noauth', group='glance') image_service.get_image_service(image_href, context=self.context) glance_service_mock.assert_called_once_with(mock.ANY, None, 1, self.context) - self.assertFalse(token_mock.called) + self.assertFalse(session_mock.called) self.assertIsNone(self.context.auth_token) @mock.patch.object(image_service.HttpImageService, '__init__', diff --git a/ironic/tests/unit/common/test_keystone.py b/ironic/tests/unit/common/test_keystone.py index f3e3b4cbb7..be5f5a81b7 100644 --- a/ironic/tests/unit/common/test_keystone.py +++ b/ironic/tests/unit/common/test_keystone.py @@ -12,174 +12,138 @@ # License for the specific language governing permissions and limitations # under the License. -from keystoneclient import exceptions as ksexception +from keystoneauth1 import exceptions as ksexception +from keystoneauth1 import loading as kaloading import mock +from oslo_config import cfg +from oslo_config import fixture from ironic.common import exception from ironic.common import keystone +from ironic.conf import auth as ironic_auth from ironic.tests import base -class FakeCatalog(object): - def url_for(self, **kwargs): - return 'fake-url' - - -class FakeAccessInfo(object): - def will_expire_soon(self): - pass - - -class FakeClient(object): - def __init__(self, **kwargs): - self.service_catalog = FakeCatalog() - self.auth_ref = FakeAccessInfo() - - def has_service_catalog(self): - return True - - class KeystoneTestCase(base.TestCase): def setUp(self): super(KeystoneTestCase, self).setUp() - self.config(group='keystone_authtoken', - auth_uri='http://127.0.0.1:9898/', - admin_user='fake', admin_password='fake', - admin_tenant_name='fake') - self.config(group='keystone', region_name='fake') - keystone._KS_CLIENT = None + self.config(region_name='fake_region', + group='keystone') + self.test_group = 'test_group' + self.cfg_fixture.conf.register_group(cfg.OptGroup(self.test_group)) + ironic_auth.register_auth_opts(self.cfg_fixture.conf, self.test_group) + self.config(auth_type='password', + group=self.test_group) + # NOTE(pas-ha) this is due to auth_plugin options + # being dynamically registered on first load, + # but we need to set the config before + plugin = kaloading.get_plugin_loader('password') + opts = kaloading.get_auth_plugin_conf_options(plugin) + self.cfg_fixture.register_opts(opts, group=self.test_group) + self.config(auth_url='http://127.0.0.1:9898', + username='fake_user', + password='fake_pass', + project_name='fake_tenant', + group=self.test_group) - def test_failure_authorization(self): - self.assertRaises(exception.KeystoneFailure, keystone.get_service_url) + def _set_config(self): + self.cfg_fixture = self.useFixture(fixture.Config()) + self.addCleanup(cfg.CONF.reset) - @mock.patch.object(FakeCatalog, 'url_for', autospec=True) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_get_url(self, mock_ks, mock_uf): + def test_get_url(self): fake_url = 'http://127.0.0.1:6385' - mock_uf.return_value = fake_url - mock_ks.return_value = FakeClient() - res = keystone.get_service_url() + mock_sess = mock.Mock() + mock_sess.get_endpoint.return_value = fake_url + res = keystone.get_service_url(mock_sess) self.assertEqual(fake_url, res) - @mock.patch.object(FakeCatalog, 'url_for', autospec=True) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_url_not_found(self, mock_ks, mock_uf): - mock_uf.side_effect = ksexception.EndpointNotFound - mock_ks.return_value = FakeClient() - self.assertRaises(exception.CatalogNotFound, keystone.get_service_url) + def test_get_url_failure(self): + exc_map = ( + (ksexception.Unauthorized, exception.KeystoneUnauthorized), + (ksexception.EndpointNotFound, exception.CatalogNotFound), + (ksexception.EmptyCatalog, exception.CatalogNotFound), + (ksexception.Unauthorized, exception.KeystoneUnauthorized), + ) + for kexc, irexc in exc_map: + mock_sess = mock.Mock() + mock_sess.get_endpoint.side_effect = kexc + self.assertRaises(irexc, keystone.get_service_url, mock_sess) - @mock.patch.object(FakeClient, 'has_service_catalog', autospec=True) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_no_catalog(self, mock_ks, mock_hsc): - mock_hsc.return_value = False - mock_ks.return_value = FakeClient() - self.assertRaises(exception.KeystoneFailure, keystone.get_service_url) + def test_get_admin_auth_token(self): + mock_sess = mock.Mock() + mock_sess.get_token.return_value = 'fake_token' + self.assertEqual('fake_token', + keystone.get_admin_auth_token(mock_sess)) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_unauthorized(self, mock_ks): - mock_ks.side_effect = ksexception.Unauthorized + def test_get_admin_auth_token_failure(self): + mock_sess = mock.Mock() + mock_sess.get_token.side_effect = ksexception.Unauthorized self.assertRaises(exception.KeystoneUnauthorized, - keystone.get_service_url) + keystone.get_admin_auth_token, mock_sess) - def test_get_service_url_fail_missing_auth_uri(self): - self.config(group='keystone_authtoken', auth_uri=None) - self.assertRaises(exception.KeystoneFailure, - keystone.get_service_url) + @mock.patch.object(ironic_auth, 'load_auth') + def test_get_session(self, auth_get_mock): + auth_mock = mock.Mock() + auth_get_mock.return_value = auth_mock + session = keystone.get_session(self.test_group) + self.assertEqual(auth_mock, session.auth) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_get_service_url_versionless_v2(self, mock_ks): - mock_ks.return_value = FakeClient() - self.config(group='keystone_authtoken', auth_uri='http://127.0.0.1') - expected_url = 'http://127.0.0.1/v2.0' - keystone.get_service_url() - mock_ks.assert_called_once_with(username='fake', password='fake', - tenant_name='fake', - region_name='fake', - auth_url=expected_url) + @mock.patch.object(keystone, '_get_legacy_auth', return_value=None) + @mock.patch.object(ironic_auth, 'load_auth', return_value=None) + def test_get_session_fail(self, auth_get_mock, legacy_get_mock): + self.assertRaisesRegexp( + exception.KeystoneFailure, + "Failed to load auth from either", + keystone.get_session, self.test_group) - @mock.patch('keystoneclient.v3.client.Client', autospec=True) - def test_get_service_url_versionless_v3(self, mock_ks): - mock_ks.return_value = FakeClient() - self.config(group='keystone_authtoken', auth_version='v3.0', - auth_uri='http://127.0.0.1') - expected_url = 'http://127.0.0.1/v3' - keystone.get_service_url() - mock_ks.assert_called_once_with(username='fake', password='fake', - tenant_name='fake', - region_name='fake', - auth_url=expected_url) + @mock.patch('keystoneauth1.loading.load_auth_from_conf_options') + @mock.patch('ironic.common.keystone._get_legacy_auth') + def test_get_session_failed_new_auth(self, legacy_get_mock, load_mock): + legacy_mock = mock.Mock() + legacy_get_mock.return_value = legacy_mock + load_mock.side_effect = [None, ksexception.MissingRequiredOptions] + self.assertEqual(legacy_mock, + keystone.get_session(self.test_group).auth) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_get_service_url_version_override(self, mock_ks): - mock_ks.return_value = FakeClient() - self.config(group='keystone_authtoken', - auth_uri='http://127.0.0.1/v2.0/') - expected_url = 'http://127.0.0.1/v2.0' - keystone.get_service_url() - mock_ks.assert_called_once_with(username='fake', password='fake', - tenant_name='fake', - region_name='fake', - auth_url=expected_url) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_get_admin_auth_token(self, mock_ks): - fake_client = FakeClient() - fake_client.auth_token = '123456' - mock_ks.return_value = fake_client - self.assertEqual('123456', keystone.get_admin_auth_token()) +@mock.patch('keystoneauth1.loading._plugins.identity.generic.Password.' + 'load_from_options') +class KeystoneLegacyTestCase(base.TestCase): + def setUp(self): + super(KeystoneLegacyTestCase, self).setUp() + self.test_group = 'test_group' + self.cfg_fixture.conf.register_group(cfg.OptGroup(self.test_group)) + self.config(group=ironic_auth.LEGACY_SECTION, + auth_uri='http://127.0.0.1:9898', + admin_user='fake_user', + admin_password='fake_pass', + admin_tenant_name='fake_tenant') + ironic_auth.register_auth_opts(self.cfg_fixture.conf, self.test_group) + self.config(group=self.test_group, + auth_type=None) + self.expected = dict( + auth_url='http://127.0.0.1:9898', + username='fake_user', + password='fake_pass', + tenant_name='fake_tenant') - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_get_region_name_v2(self, mock_ks): - mock_ks.return_value = FakeClient() - self.config(group='keystone', region_name='fake_region') - expected_url = 'http://127.0.0.1:9898/v2.0' - expected_region = 'fake_region' - keystone.get_service_url() - mock_ks.assert_called_once_with(username='fake', password='fake', - tenant_name='fake', - region_name=expected_region, - auth_url=expected_url) + def _set_config(self): + self.cfg_fixture = self.useFixture(fixture.Config()) + self.addCleanup(cfg.CONF.reset) - @mock.patch('keystoneclient.v3.client.Client', autospec=True) - def test_get_region_name_v3(self, mock_ks): - mock_ks.return_value = FakeClient() - self.config(group='keystone', region_name='fake_region') - self.config(group='keystone_authtoken', auth_version='v3.0') - expected_url = 'http://127.0.0.1:9898/v3' - expected_region = 'fake_region' - keystone.get_service_url() - mock_ks.assert_called_once_with(username='fake', password='fake', - tenant_name='fake', - region_name=expected_region, - auth_url=expected_url) + @mock.patch.object(ironic_auth, 'load_auth', return_value=None) + def test_legacy_loading_v2(self, load_auth_mock, load_mock): + keystone.get_session(self.test_group) + load_mock.assert_called_once_with(**self.expected) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_cache_client_init(self, mock_ks): - fake_client = FakeClient() - mock_ks.return_value = fake_client - self.assertEqual(fake_client, keystone._get_ksclient()) - self.assertEqual(fake_client, keystone._KS_CLIENT) - self.assertEqual(1, mock_ks.call_count) - - @mock.patch.object(FakeAccessInfo, 'will_expire_soon', autospec=True) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_cache_client_cached(self, mock_ks, mock_expire): - mock_expire.return_value = False - fake_client = FakeClient() - keystone._KS_CLIENT = fake_client - self.assertEqual(fake_client, keystone._get_ksclient()) - self.assertEqual(fake_client, keystone._KS_CLIENT) - self.assertFalse(mock_ks.called) - - @mock.patch.object(FakeAccessInfo, 'will_expire_soon', autospec=True) - @mock.patch('keystoneclient.v2_0.client.Client', autospec=True) - def test_cache_client_expired(self, mock_ks, mock_expire): - mock_expire.return_value = True - fake_client = FakeClient() - keystone._KS_CLIENT = fake_client - new_client = FakeClient() - mock_ks.return_value = new_client - self.assertEqual(new_client, keystone._get_ksclient()) - self.assertEqual(new_client, keystone._KS_CLIENT) - self.assertEqual(1, mock_ks.call_count) + @mock.patch.object(ironic_auth, 'load_auth', return_value=None) + def test_legacy_loading_v3(self, load_auth_mock, load_mock): + self.config( + auth_version='v3.0', + group=ironic_auth.LEGACY_SECTION) + self.expected.update(dict( + project_domain_id='default', + user_domain_id='default')) + keystone.get_session(self.test_group) + load_mock.assert_called_once_with(**self.expected) diff --git a/ironic/tests/unit/common/test_neutron.py b/ironic/tests/unit/common/test_neutron.py index 0076d741ae..54c2f967f8 100644 --- a/ironic/tests/unit/common/test_neutron.py +++ b/ironic/tests/unit/common/test_neutron.py @@ -19,86 +19,80 @@ from oslo_utils import uuidutils from ironic.common import exception from ironic.common import neutron from ironic.conductor import task_manager +# from ironic.conf import auth as ironic_auth from ironic.tests import base from ironic.tests.unit.conductor import mgr_utils from ironic.tests.unit.db import base as db_base from ironic.tests.unit.objects import utils as object_utils +@mock.patch.object(neutron, '_get_neutron_session') +@mock.patch.object(client.Client, "__init__") class TestNeutronClient(base.TestCase): def setUp(self): super(TestNeutronClient, self).setUp() - self.config(url='test-url', - url_timeout=30, + self.config(url_timeout=30, retries=2, group='neutron') - self.config(insecure=False, - certfile='test-file', - admin_user='test-admin-user', + self.config(admin_user='test-admin-user', admin_tenant_name='test-admin-tenant', admin_password='test-admin-password', auth_uri='test-auth-uri', group='keystone_authtoken') + # TODO(pas-ha) register session options to test legacy path + self.config(insecure=False, + cafile='test-file', + group='neutron') - @mock.patch.object(client.Client, "__init__") - def test_get_neutron_client_with_token(self, mock_client_init): + def test_get_neutron_client_with_token(self, mock_client_init, + mock_session): token = 'test-token-123' + sess = mock.Mock() + sess.get_endpoint.return_value = 'fake-url' + mock_session.return_value = sess expected = {'timeout': 30, 'retries': 2, 'insecure': False, 'ca_cert': 'test-file', 'token': token, - 'endpoint_url': 'test-url', - 'username': 'test-admin-user', - 'tenant_name': 'test-admin-tenant', - 'password': 'test-admin-password', - 'auth_url': 'test-auth-uri'} + 'endpoint_url': 'fake-url'} mock_client_init.return_value = None neutron.get_client(token=token) mock_client_init.assert_called_once_with(**expected) - @mock.patch.object(client.Client, "__init__") - def test_get_neutron_client_without_token(self, mock_client_init): - expected = {'timeout': 30, - 'retries': 2, - 'insecure': False, - 'ca_cert': 'test-file', - 'token': None, - 'endpoint_url': 'test-url', - 'username': 'test-admin-user', - 'tenant_name': 'test-admin-tenant', - 'password': 'test-admin-password', - 'auth_url': 'test-auth-uri'} - + def test_get_neutron_client_without_token(self, mock_client_init, + mock_session): + self.config(url='test-url', + group='neutron') + sess = mock.Mock() + mock_session.return_value = sess + expected = {'retries': 2, + 'endpoint_override': 'test-url', + 'session': sess} mock_client_init.return_value = None neutron.get_client(token=None) mock_client_init.assert_called_once_with(**expected) - @mock.patch.object(client.Client, "__init__") - def test_get_neutron_client_with_region(self, mock_client_init): - expected = {'timeout': 30, - 'retries': 2, - 'insecure': False, - 'ca_cert': 'test-file', - 'token': None, - 'endpoint_url': 'test-url', - 'username': 'test-admin-user', - 'tenant_name': 'test-admin-tenant', - 'password': 'test-admin-password', - 'auth_url': 'test-auth-uri', - 'region_name': 'test-region'} - - self.config(region_name='test-region', + def test_get_neutron_client_with_region(self, mock_client_init, + mock_session): + self.config(region_name='fake_region', group='keystone') + sess = mock.Mock() + mock_session.return_value = sess + expected = {'retries': 2, + 'region_name': 'fake_region', + 'session': sess} + mock_client_init.return_value = None neutron.get_client(token=None) mock_client_init.assert_called_once_with(**expected) - @mock.patch.object(client.Client, "__init__") - def test_get_neutron_client_noauth(self, mock_client_init): - self.config(auth_strategy='noauth', group='neutron') + def test_get_neutron_client_noauth(self, mock_client_init, mock_session): + self.config(auth_strategy='noauth', + url='test-url', + group='neutron') expected = {'ca_cert': 'test-file', 'insecure': False, 'endpoint_url': 'test-url', @@ -110,7 +104,7 @@ class TestNeutronClient(base.TestCase): neutron.get_client(token=None) mock_client_init.assert_called_once_with(**expected) - def test_out_range_auth_strategy(self): + def test_out_range_auth_strategy(self, mock_client_init, mock_session): self.assertRaises(ValueError, cfg.CONF.set_override, 'auth_strategy', 'fake', 'neutron', enforce_type=True) @@ -133,9 +127,13 @@ class TestNeutronNetworkActions(db_base.DbTestCase): self.neutron_port = {'id': '132f871f-eaec-4fed-9475-0d54465e0f00', 'mac_address': '52:54:00:cf:2d:32'} self.network_uuid = uuidutils.generate_uuid() + self.client_mock = mock.Mock() + patcher = mock.patch('ironic.common.neutron.get_client', + return_value=self.client_mock) + patcher.start() + self.addCleanup(patcher.stop) - @mock.patch.object(client.Client, 'create_port') - def test_add_ports_to_vlan_network(self, create_mock): + def test_add_ports_to_vlan_network(self): # Ports will be created only if pxe_enabled is True object_utils.create_test_port( self.context, node_id=self.node.id, @@ -159,15 +157,16 @@ class TestNeutronNetworkActions(db_base.DbTestCase): } } # Ensure we can create ports - create_mock.return_value = {'port': self.neutron_port} + self.client_mock.create_port.return_value = { + 'port': self.neutron_port} expected = {port.uuid: self.neutron_port['id']} with task_manager.acquire(self.context, self.node.uuid) as task: ports = neutron.add_ports_to_network(task, self.network_uuid) self.assertEqual(expected, ports) - create_mock.assert_called_once_with(expected_body) + self.client_mock.create_port.assert_called_once_with( + expected_body) - @mock.patch.object(client.Client, 'create_port') - def test_add_ports_to_flat_network(self, create_mock): + def test_add_ports_to_flat_network(self): port = self.ports[0] expected_body = { 'port': { @@ -183,16 +182,17 @@ class TestNeutronNetworkActions(db_base.DbTestCase): } } # Ensure we can create ports - create_mock.return_value = {'port': self.neutron_port} + self.client_mock.create_port.return_value = { + 'port': self.neutron_port} expected = {port.uuid: self.neutron_port['id']} with task_manager.acquire(self.context, self.node.uuid) as task: ports = neutron.add_ports_to_network(task, self.network_uuid, is_flat=True) self.assertEqual(expected, ports) - create_mock.assert_called_once_with(expected_body) + self.client_mock.create_port.assert_called_once_with( + expected_body) - @mock.patch.object(client.Client, 'create_port') - def test_add_ports_to_flat_network_no_neutron_port_id(self, create_mock): + def test_add_ports_to_flat_network_no_neutron_port_id(self): port = self.ports[0] expected_body = { 'port': { @@ -208,15 +208,16 @@ class TestNeutronNetworkActions(db_base.DbTestCase): } } del self.neutron_port['id'] - create_mock.return_value = {'port': self.neutron_port} + self.client_mock.create_port.return_value = { + 'port': self.neutron_port} with task_manager.acquire(self.context, self.node.uuid) as task: self.assertRaises(exception.NetworkError, neutron.add_ports_to_network, task, self.network_uuid, is_flat=True) - create_mock.assert_called_once_with(expected_body) + self.client_mock.create_port.assert_called_once_with( + expected_body) - @mock.patch.object(client.Client, 'create_port') - def test_add_ports_to_vlan_network_instance_uuid(self, create_mock): + def test_add_ports_to_vlan_network_instance_uuid(self): self.node.instance_uuid = uuidutils.generate_uuid() self.node.save() port = self.ports[0] @@ -235,18 +236,18 @@ class TestNeutronNetworkActions(db_base.DbTestCase): } } # Ensure we can create ports - create_mock.return_value = {'port': self.neutron_port} + self.client_mock.create_port.return_value = {'port': self.neutron_port} expected = {port.uuid: self.neutron_port['id']} with task_manager.acquire(self.context, self.node.uuid) as task: ports = neutron.add_ports_to_network(task, self.network_uuid) self.assertEqual(expected, ports) - create_mock.assert_called_once_with(expected_body) + self.client_mock.create_port.assert_called_once_with(expected_body) @mock.patch.object(neutron, 'rollback_ports') - @mock.patch.object(client.Client, 'create_port') - def test_add_network_fail(self, create_mock, rollback_mock): + def test_add_network_fail(self, rollback_mock): # Check that if creating a port fails, the ports are cleaned up - create_mock.side_effect = neutron_client_exc.ConnectionFailed + self.client_mock.create_port.side_effect = \ + neutron_client_exc.ConnectionFailed with task_manager.acquire(self.context, self.node.uuid) as task: self.assertRaisesRegex( @@ -255,9 +256,8 @@ class TestNeutronNetworkActions(db_base.DbTestCase): rollback_mock.assert_called_once_with(task, self.network_uuid) @mock.patch.object(neutron, 'rollback_ports') - @mock.patch.object(client.Client, 'create_port', return_value={}) - def test_add_network_fail_create_any_port_empty(self, create_mock, - rollback_mock): + def test_add_network_fail_create_any_port_empty(self, rollback_mock): + self.client_mock.create_port.return_value = {} with task_manager.acquire(self.context, self.node.uuid) as task: self.assertRaisesRegex( exception.NetworkError, 'any PXE enabled port', @@ -266,16 +266,16 @@ class TestNeutronNetworkActions(db_base.DbTestCase): @mock.patch.object(neutron, 'LOG') @mock.patch.object(neutron, 'rollback_ports') - @mock.patch.object(client.Client, 'create_port') - def test_add_network_fail_create_some_ports_empty(self, create_mock, - rollback_mock, log_mock): + def test_add_network_fail_create_some_ports_empty(self, rollback_mock, + log_mock): port2 = object_utils.create_test_port( self.context, node_id=self.node.id, uuid=uuidutils.generate_uuid(), address='52:54:55:cf:2d:32', extra={'vif_port_id': uuidutils.generate_uuid()} ) - create_mock.side_effect = [{'port': self.neutron_port}, {}] + self.client_mock.create_port.side_effect = [ + {'port': self.neutron_port}, {}] with task_manager.acquire(self.context, self.node.uuid) as task: neutron.add_ports_to_network(task, self.network_uuid) self.assertIn(str(port2.uuid), @@ -309,35 +309,39 @@ class TestNeutronNetworkActions(db_base.DbTestCase): 'mac_address': [self.ports[0].address]} ) - @mock.patch.object(client.Client, 'delete_port') - @mock.patch.object(client.Client, 'list_ports') - def test_remove_neutron_ports(self, list_mock, delete_mock): + def test_remove_neutron_ports(self): with task_manager.acquire(self.context, self.node.uuid) as task: - list_mock.return_value = {'ports': [self.neutron_port]} + self.client_mock.list_ports.return_value = { + 'ports': [self.neutron_port]} neutron.remove_neutron_ports(task, {'param': 'value'}) - list_mock.assert_called_once_with(**{'param': 'value'}) - delete_mock.assert_called_once_with(self.neutron_port['id']) + self.client_mock.list_ports.assert_called_once_with( + **{'param': 'value'}) + self.client_mock.delete_port.assert_called_once_with( + self.neutron_port['id']) - @mock.patch.object(client.Client, 'list_ports') - def test_remove_neutron_ports_list_fail(self, list_mock): + def test_remove_neutron_ports_list_fail(self): with task_manager.acquire(self.context, self.node.uuid) as task: - list_mock.side_effect = neutron_client_exc.ConnectionFailed + self.client_mock.list_ports.side_effect = \ + neutron_client_exc.ConnectionFailed self.assertRaisesRegex( exception.NetworkError, 'Could not get given network VIF', neutron.remove_neutron_ports, task, {'param': 'value'}) - list_mock.assert_called_once_with(**{'param': 'value'}) + self.client_mock.list_ports.assert_called_once_with( + **{'param': 'value'}) - @mock.patch.object(client.Client, 'delete_port') - @mock.patch.object(client.Client, 'list_ports') - def test_remove_neutron_ports_delete_fail(self, list_mock, delete_mock): + def test_remove_neutron_ports_delete_fail(self): with task_manager.acquire(self.context, self.node.uuid) as task: - delete_mock.side_effect = neutron_client_exc.ConnectionFailed - list_mock.return_value = {'ports': [self.neutron_port]} + self.client_mock.delete_port.side_effect = \ + neutron_client_exc.ConnectionFailed + self.client_mock.list_ports.return_value = { + 'ports': [self.neutron_port]} self.assertRaisesRegex( exception.NetworkError, 'Could not remove VIF', neutron.remove_neutron_ports, task, {'param': 'value'}) - list_mock.assert_called_once_with(**{'param': 'value'}) - delete_mock.assert_called_once_with(self.neutron_port['id']) + self.client_mock.list_ports.assert_called_once_with( + **{'param': 'value'}) + self.client_mock.delete_port.assert_called_once_with( + self.neutron_port['id']) def test_get_node_portmap(self): with task_manager.acquire(self.context, self.node.uuid) as task: diff --git a/ironic/tests/unit/common/test_swift.py b/ironic/tests/unit/common/test_swift.py index e5e91fec76..e5bc306fd1 100644 --- a/ironic/tests/unit/common/test_swift.py +++ b/ironic/tests/unit/common/test_swift.py @@ -30,6 +30,7 @@ if six.PY3: file = io.BytesIO +@mock.patch.object(swift, '_get_swift_session') @mock.patch.object(swift_client, 'Connection', autospec=True) class SwiftTestCase(base.TestCase): @@ -37,42 +38,22 @@ class SwiftTestCase(base.TestCase): super(SwiftTestCase, self).setUp() self.swift_exception = swift_exception.ClientException('', '') - self.config(admin_user='admin', group='keystone_authtoken') - self.config(admin_tenant_name='tenant', group='keystone_authtoken') - self.config(admin_password='password', group='keystone_authtoken') - self.config(auth_uri='http://authurl', group='keystone_authtoken') - self.config(auth_version='2', group='keystone_authtoken') - self.config(swift_max_retries=2, group='swift') - self.config(insecure=0, group='keystone_authtoken') - self.config(cafile='/path/to/ca/file', group='keystone_authtoken') - self.expected_params = {'retries': 2, - 'insecure': 0, - 'user': 'admin', - 'tenant_name': 'tenant', - 'key': 'password', - 'authurl': 'http://authurl/v2.0', - 'cacert': '/path/to/ca/file', - 'auth_version': '2'} - - def test___init__(self, connection_mock): + def test___init__(self, connection_mock, keystone_mock): + sess = mock.Mock() + sess.get_endpoint.return_value = 'http://swift:8080' + sess.get_token.return_value = 'fake_token' + sess.verify = '/path/to/ca/file' + keystone_mock.return_value = sess swift.SwiftAPI() - connection_mock.assert_called_once_with(**self.expected_params) - - def test__init__with_region_from_config(self, connection_mock): - self.config(region_name='region1', group='keystone_authtoken') - swift.SwiftAPI() - params = self.expected_params.copy() - params['os_options'] = {'region_name': 'region1'} - connection_mock.assert_called_once_with(**params) - - def test__init__with_region_from_constructor(self, connection_mock): - swift.SwiftAPI(region_name='region1') - params = self.expected_params.copy() - params['os_options'] = {'region_name': 'region1'} + params = {'retries': 2, + 'preauthurl': 'http://swift:8080', + 'preauthtoken': 'fake_token', + 'insecure': False, + 'cacert': '/path/to/ca/file'} connection_mock.assert_called_once_with(**params) @mock.patch.object(__builtin__, 'open', autospec=True) - def test_create_object(self, open_mock, connection_mock): + def test_create_object(self, open_mock, connection_mock, keystone_mock): swiftapi = swift.SwiftAPI() connection_obj_mock = connection_mock.return_value mock_file_handle = mock.MagicMock(spec=file) @@ -91,7 +72,8 @@ class SwiftTestCase(base.TestCase): @mock.patch.object(__builtin__, 'open', autospec=True) def test_create_object_create_container_fails(self, open_mock, - connection_mock): + connection_mock, + keystone_mock): swiftapi = swift.SwiftAPI() connection_obj_mock = connection_mock.return_value connection_obj_mock.put_container.side_effect = self.swift_exception @@ -102,7 +84,8 @@ class SwiftTestCase(base.TestCase): self.assertFalse(connection_obj_mock.put_object.called) @mock.patch.object(__builtin__, 'open', autospec=True) - def test_create_object_put_object_fails(self, open_mock, connection_mock): + def test_create_object_put_object_fails(self, open_mock, connection_mock, + keystone_mock): swiftapi = swift.SwiftAPI() mock_file_handle = mock.MagicMock(spec=file) mock_file_handle.__enter__.return_value = 'file-object' @@ -118,30 +101,30 @@ class SwiftTestCase(base.TestCase): 'container', 'object', 'file-object', headers=None) @mock.patch.object(swift_utils, 'generate_temp_url', autospec=True) - def test_get_temp_url(self, gen_temp_url_mock, connection_mock): + def test_get_temp_url(self, gen_temp_url_mock, connection_mock, + keystone_mock): swiftapi = swift.SwiftAPI() connection_obj_mock = connection_mock.return_value - auth = ['http://host/v1/AUTH_tenant_id', 'token'] - connection_obj_mock.get_auth.return_value = auth + connection_obj_mock.url = 'http://host/v1/AUTH_tenant_id' head_ret_val = {'x-account-meta-temp-url-key': 'secretkey'} connection_obj_mock.head_account.return_value = head_ret_val gen_temp_url_mock.return_value = 'temp-url-path' temp_url_returned = swiftapi.get_temp_url('container', 'object', 10) - connection_obj_mock.get_auth.assert_called_once_with() connection_obj_mock.head_account.assert_called_once_with() object_path_expected = '/v1/AUTH_tenant_id/container/object' gen_temp_url_mock.assert_called_once_with(object_path_expected, 10, 'secretkey', 'GET') self.assertEqual('http://host/temp-url-path', temp_url_returned) - def test_delete_object(self, connection_mock): + def test_delete_object(self, connection_mock, keystone_mock): swiftapi = swift.SwiftAPI() connection_obj_mock = connection_mock.return_value swiftapi.delete_object('container', 'object') connection_obj_mock.delete_object.assert_called_once_with('container', 'object') - def test_delete_object_exc_resource_not_found(self, connection_mock): + def test_delete_object_exc_resource_not_found(self, connection_mock, + keystone_mock): swiftapi = swift.SwiftAPI() exc = swift_exception.ClientException( "Resource not found", http_status=http_client.NOT_FOUND) @@ -152,7 +135,7 @@ class SwiftTestCase(base.TestCase): connection_obj_mock.delete_object.assert_called_once_with('container', 'object') - def test_delete_object_exc(self, connection_mock): + def test_delete_object_exc(self, connection_mock, keystone_mock): swiftapi = swift.SwiftAPI() exc = swift_exception.ClientException("Operation error") connection_obj_mock = connection_mock.return_value @@ -162,7 +145,7 @@ class SwiftTestCase(base.TestCase): connection_obj_mock.delete_object.assert_called_once_with('container', 'object') - def test_head_object(self, connection_mock): + def test_head_object(self, connection_mock, keystone_mock): swiftapi = swift.SwiftAPI() connection_obj_mock = connection_mock.return_value expected_head_result = {'a': 'b'} @@ -172,7 +155,7 @@ class SwiftTestCase(base.TestCase): 'object') self.assertEqual(expected_head_result, actual_head_result) - def test_update_object_meta(self, connection_mock): + def test_update_object_meta(self, connection_mock, keystone_mock): swiftapi = swift.SwiftAPI() connection_obj_mock = connection_mock.return_value headers = {'a': 'b'} diff --git a/ironic/tests/unit/conf/__init__.py b/ironic/tests/unit/conf/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/conf/test_auth.py b/ironic/tests/unit/conf/test_auth.py new file mode 100644 index 0000000000..369e5d4d35 --- /dev/null +++ b/ironic/tests/unit/conf/test_auth.py @@ -0,0 +1,70 @@ +# Copyright 2016 Mirantis Inc +# +# 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 keystoneauth1 import identity as kaidentity +from keystoneauth1 import loading as kaloading +from oslo_config import cfg + +from ironic.conf import auth as ironic_auth +from ironic.tests import base + + +class AuthConfTestCase(base.TestCase): + + def setUp(self): + super(AuthConfTestCase, self).setUp() + self.config(region_name='fake_region', + group='keystone') + self.test_group = 'test_group' + self.cfg_fixture.conf.register_group(cfg.OptGroup(self.test_group)) + ironic_auth.register_auth_opts(self.cfg_fixture.conf, self.test_group) + self.config(auth_type='password', + group=self.test_group) + # NOTE(pas-ha) this is due to auth_plugin options + # being dynamically registered on first load, + # but we need to set the config before + plugin = kaloading.get_plugin_loader('password') + opts = kaloading.get_auth_plugin_conf_options(plugin) + self.cfg_fixture.register_opts(opts, group=self.test_group) + self.config(auth_url='http://127.0.0.1:9898', + username='fake_user', + password='fake_pass', + project_name='fake_tenant', + group=self.test_group) + + def test_add_auth_opts(self): + opts = ironic_auth.add_auth_opts([]) + # check that there is no duplicates + names = {o.dest for o in opts} + self.assertEqual(len(names), len(opts)) + # NOTE(pas-ha) checking for most standard auth and session ones only + expected = {'timeout', 'insecure', 'cafile', 'certfile', 'keyfile', + 'auth_type', 'auth_url', 'username', 'password', + 'tenant_name', 'project_name', 'trust_id', + 'domain_id', 'user_domain_id', 'project_domain_id'} + self.assertTrue(expected.issubset(names)) + + def test_load_auth(self): + auth = ironic_auth.load_auth(self.cfg_fixture.conf, self.test_group) + # NOTE(pas-ha) 'password' auth_plugin is used + self.assertIsInstance(auth, kaidentity.generic.password.Password) + self.assertEqual('http://127.0.0.1:9898', auth.auth_url) + + def test_load_auth_missing_options(self): + # NOTE(pas-ha) 'password' auth_plugin is used, + # so when we set the required auth_url to None, + # MissingOption is raised + self.config(auth_url=None, group=self.test_group) + self.assertIsNone(ironic_auth.load_auth( + self.cfg_fixture.conf, self.test_group)) diff --git a/ironic/tests/unit/drivers/modules/test_deploy_utils.py b/ironic/tests/unit/drivers/modules/test_deploy_utils.py index 44626a1a72..9a26c3680e 100644 --- a/ironic/tests/unit/drivers/modules/test_deploy_utils.py +++ b/ironic/tests/unit/drivers/modules/test_deploy_utils.py @@ -31,7 +31,6 @@ from ironic.common import boot_devices from ironic.common import dhcp_factory from ironic.common import exception from ironic.common import image_service -from ironic.common import keystone from ironic.common import states from ironic.common import utils as common_utils from ironic.conductor import task_manager @@ -1381,6 +1380,42 @@ class OtherFunctionTestCase(db_base.DbTestCase): utils.warn_about_unsafe_shred_parameters() self.assertTrue(log_mock.warning.called) + @mock.patch.object(utils, '_get_ironic_session') + @mock.patch('ironic.common.keystone.get_service_url') + def test_get_ironic_api_url_from_config(self, mock_get_url, mock_ks): + mock_sess = mock.Mock() + mock_ks.return_value = mock_sess + fake_api_url = 'http://foo/' + mock_get_url.side_effect = exception.KeystoneFailure + self.config(api_url=fake_api_url, group='conductor') + url = utils.get_ironic_api_url() + # also checking for stripped trailing slash + self.assertEqual(fake_api_url[:-1], url) + self.assertFalse(mock_get_url.called) + + @mock.patch.object(utils, '_get_ironic_session') + @mock.patch('ironic.common.keystone.get_service_url') + def test_get_ironic_api_url_from_keystone(self, mock_get_url, mock_ks): + mock_sess = mock.Mock() + mock_ks.return_value = mock_sess + fake_api_url = 'http://foo/' + mock_get_url.return_value = fake_api_url + self.config(api_url=None, group='conductor') + url = utils.get_ironic_api_url() + # also checking for stripped trailing slash + self.assertEqual(fake_api_url[:-1], url) + mock_get_url.assert_called_with(mock_sess) + + @mock.patch.object(utils, '_get_ironic_session') + @mock.patch('ironic.common.keystone.get_service_url') + def test_get_ironic_api_url_fail(self, mock_get_url, mock_ks): + mock_sess = mock.Mock() + mock_ks.return_value = mock_sess + mock_get_url.side_effect = exception.KeystoneFailure() + self.config(api_url=None, group='conductor') + self.assertRaises(exception.InvalidParameterValue, + utils.get_ironic_api_url) + class VirtualMediaDeployUtilsTestCase(db_base.DbTestCase): @@ -1923,11 +1958,12 @@ class AgentMethodsTestCase(db_base.DbTestCase): self.assertEqual('fake_agent', options['ipa-driver-name']) self.assertEqual(0, options['coreos.configdrive']) - @mock.patch.object(keystone, 'get_service_url', autospec=True) - def test_build_agent_options_keystone(self, get_url_mock): - + @mock.patch.object(utils, '_get_ironic_session') + def test_build_agent_options_keystone(self, session_mock): self.config(api_url=None, group='conductor') - get_url_mock.return_value = 'api-url' + sess = mock.Mock() + sess.get_endpoint.return_value = 'api-url' + session_mock.return_value = sess options = utils.build_agent_options(self.node) self.assertEqual('api-url', options['ipa-api-url']) self.assertEqual('fake_agent', options['ipa-driver-name']) diff --git a/ironic/tests/unit/drivers/modules/test_inspector.py b/ironic/tests/unit/drivers/modules/test_inspector.py index 132a2e4a1c..7124cf4bbc 100644 --- a/ironic/tests/unit/drivers/modules/test_inspector.py +++ b/ironic/tests/unit/drivers/modules/test_inspector.py @@ -16,7 +16,6 @@ import mock from ironic.common import driver_factory from ironic.common import exception -from ironic.common import keystone from ironic.common import states from ironic.conductor import task_manager from ironic.drivers.modules import inspector @@ -128,12 +127,17 @@ class InspectHardwareTestCase(BaseTestCase): task.process_event.assert_called_once_with('fail') -@mock.patch.object(keystone, 'get_admin_auth_token', lambda: 'the token') @mock.patch.object(client, 'get_status') class CheckStatusTestCase(BaseTestCase): def setUp(self): super(CheckStatusTestCase, self).setUp() self.node.provision_state = states.INSPECTING + mock_session = mock.Mock() + mock_session.get_token.return_value = 'the token' + sess_patch = mock.patch.object(inspector, '_get_inspector_session', + return_value=mock_session) + sess_patch.start() + self.addCleanup(sess_patch.stop) def test_not_inspecting(self, mock_get): self.node.provision_state = states.MANAGEABLE diff --git a/ironic/tests/unit/drivers/modules/test_iscsi_deploy.py b/ironic/tests/unit/drivers/modules/test_iscsi_deploy.py index 20df419a1a..56ca5d46b9 100644 --- a/ironic/tests/unit/drivers/modules/test_iscsi_deploy.py +++ b/ironic/tests/unit/drivers/modules/test_iscsi_deploy.py @@ -27,7 +27,6 @@ from oslo_utils import fileutils from ironic.common import dhcp_factory from ironic.common import driver_factory from ironic.common import exception -from ironic.common import keystone from ironic.common import pxe_utils from ironic.common import states from ironic.common import utils @@ -446,38 +445,22 @@ class IscsiDeployMethodsTestCase(db_base.DbTestCase): self.assertEqual(states.ACTIVE, self.node.target_provision_state) self.assertIsNotNone(self.node.last_error) - @mock.patch.object(keystone, 'get_service_url', autospec=True) - def test_validate_good_api_url_from_config_file(self, mock_ks): - # not present in the keystone catalog - mock_ks.side_effect = exception.KeystoneFailure - self.config(group='conductor', api_url='http://foo') + @mock.patch('ironic.drivers.modules.deploy_utils.get_ironic_api_url') + def test_validate_good_api_url(self, mock_get_url): + mock_get_url.return_value = 'http://127.0.0.1:1234' with task_manager.acquire(self.context, self.node.uuid, shared=True) as task: iscsi_deploy.validate(task) - self.assertFalse(mock_ks.called) + mock_get_url.assert_called_once_with() - @mock.patch.object(keystone, 'get_service_url', autospec=True) - def test_validate_good_api_url_from_keystone(self, mock_ks): - # present in the keystone catalog - mock_ks.return_value = 'http://127.0.0.1:1234' - # not present in the config file - self.config(group='conductor', api_url=None) - with task_manager.acquire(self.context, self.node.uuid, - shared=True) as task: - iscsi_deploy.validate(task) - mock_ks.assert_called_once_with() - - @mock.patch.object(keystone, 'get_service_url', autospec=True) - def test_validate_fail_no_api_url(self, mock_ks): - # not present in the keystone catalog - mock_ks.side_effect = exception.KeystoneFailure - # not present in the config file - self.config(group='conductor', api_url=None) + @mock.patch('ironic.drivers.modules.deploy_utils.get_ironic_api_url') + def test_validate_fail_no_api_url(self, mock_get_url): + mock_get_url.side_effect = exception.InvalidParameterValue('Ham!') with task_manager.acquire(self.context, self.node.uuid, shared=True) as task: self.assertRaises(exception.InvalidParameterValue, iscsi_deploy.validate, task) - mock_ks.assert_called_once_with() + mock_get_url.assert_called_once_with() def test_validate_invalid_root_device_hints(self): with task_manager.acquire(self.context, self.node.uuid, diff --git a/releasenotes/notes/keystone-auth-3155762c524e44df.yaml b/releasenotes/notes/keystone-auth-3155762c524e44df.yaml new file mode 100644 index 0000000000..0dfaf818a3 --- /dev/null +++ b/releasenotes/notes/keystone-auth-3155762c524e44df.yaml @@ -0,0 +1,43 @@ +--- +upgrade: + - | + New way of configuring access credentials for OpenStack services clients. + For each service both Keystone session options + (timeout, SSL-related ones) and Keystone auth_plugin options + (auth_url, auth_type and correspondig auth_plugin options) + should be specified in the config section for this service. + Config section affected are + + * ``[neutron]`` for Neutron service user + * ``[glance]`` for Glance service user + * ``[swift]`` for Swift service user + * ``[inspector]`` for Ironic Inspector service user + * ``[service_catalog]`` *new section* for Ironic service user, + used to discover Ironic endpoint from Keystone Catalog + + This enables fine tuning of authentification for each service. + + Backward-compatible options handling is provided + using values from ``[keystone_authtoken]`` config section, + but operators are advised to switch to the new config options. + For more information on sessions, auth plugins and their settings, + please refer to _http://docs.openstack.org/developer/keystoneauth/ + + - | + Small change in semantics of default for ``[neutron]url`` option + + * default is changed to None. + * In case when [neutron]auth_strategy is ``noauth``, + default means use ``http://$my_ip:9696``. + * In case when [neutron]auth_strategy is ``keystone``, + default means to resolve the endpoint from Keystone Catalog. + + - New config section ``[service_catalog]`` for access credentials used + to discover Ironic API URL from Keystone Catalog. + Previousely credentials from ``[keystone_authtoken]`` section were used, + which is now deprecated for such purpose. +fixes: + - Do not rely on keystonemiddleware config options for instantiating + clients for other OpenStack services. + This allows changing keystonemiddleware options from legacy ones + and thus support Keystone V3 for token validation. diff --git a/requirements.txt b/requirements.txt index 5a555fd927..7958bd9762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ netaddr!=0.7.16,>=0.7.12 # BSD paramiko>=2.0 # LGPLv2.1+ python-neutronclient>=4.2.0 # Apache-2.0 python-glanceclient>=2.0.0 # Apache-2.0 -python-keystoneclient!=1.8.0,!=2.1.0,>=1.7.0 # Apache-2.0 +keystoneauth1>=2.10.0 # Apache-2.0 ironic-lib>=2.0.0 # Apache-2.0 python-swiftclient>=2.2.0 # Apache-2.0 pytz>=2013.6 # MIT