diff options
author | dfilppi <dewayne@gigaspaces.com> | 2017-08-07 20:10:53 +0000 |
---|---|---|
committer | dfilppi <dewayne@gigaspaces.com> | 2017-08-07 20:10:53 +0000 |
commit | 9981f55920a6f1c1f20396d42e35b075b22f6a8f (patch) | |
tree | 1199993b9bae728c5274ae3062988dc9f357eb5b /aria/multivim-plugin/openstack_plugin_common | |
parent | 4538e26e2a60bd325d63c19bcc7d0fed37ccce96 (diff) |
ARIA multivim plugin initial checkin
Change-Id: I3a24ab6fc5ba54466bfecaf596a13b8907248ae8
Issue-id: SO-77
Signed-off-by: DeWayne Filppi <dewayne@gigaspaces.com>
Diffstat (limited to 'aria/multivim-plugin/openstack_plugin_common')
7 files changed, 2204 insertions, 0 deletions
diff --git a/aria/multivim-plugin/openstack_plugin_common/__init__.py b/aria/multivim-plugin/openstack_plugin_common/__init__.py new file mode 100644 index 0000000000..353b2be03f --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/__init__.py @@ -0,0 +1,1005 @@ +######### +# Copyright (c) 2014 GigaSpaces Technologies Ltd. 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 functools import wraps, partial +import json +import os +import sys + +from IPy import IP +from keystoneauth1 import loading, session +import cinderclient.client as cinder_client +import cinderclient.exceptions as cinder_exceptions +import keystoneclient.v3.client as keystone_client +import keystoneclient.exceptions as keystone_exceptions +import neutronclient.v2_0.client as neutron_client +import neutronclient.common.exceptions as neutron_exceptions +import novaclient.client as nova_client +import novaclient.exceptions as nova_exceptions +import glanceclient.client as glance_client +import glanceclient.exc as glance_exceptions + +import cloudify +from cloudify import context, ctx +from cloudify.exceptions import NonRecoverableError, RecoverableError + +INFINITE_RESOURCE_QUOTA = -1 + +# properties +USE_EXTERNAL_RESOURCE_PROPERTY = 'use_external_resource' +CREATE_IF_MISSING_PROPERTY = 'create_if_missing' +CONFIG_PROPERTY = 'openstack_config' + +# runtime properties +OPENSTACK_AZ_PROPERTY = 'availability_zone' +OPENSTACK_ID_PROPERTY = 'external_id' # resource's openstack id +OPENSTACK_TYPE_PROPERTY = 'external_type' # resource's openstack type +OPENSTACK_NAME_PROPERTY = 'external_name' # resource's openstack name +CONDITIONALLY_CREATED = 'conditionally_created' # resource was +# conditionally created +CONFIG_RUNTIME_PROPERTY = CONFIG_PROPERTY # openstack configuration + +# operation inputs +CONFIG_INPUT = CONFIG_PROPERTY + +# runtime properties which all types use +COMMON_RUNTIME_PROPERTIES_KEYS = [OPENSTACK_ID_PROPERTY, + OPENSTACK_TYPE_PROPERTY, + OPENSTACK_NAME_PROPERTY, + CONDITIONALLY_CREATED] + +MISSING_RESOURCE_MESSAGE = "Couldn't find a resource of " \ + "type {0} with the name or id {1}" + + +class ProviderContext(object): + + def __init__(self, provider_context): + self._provider_context = provider_context or {} + self._resources = self._provider_context.get('resources', {}) + + @property + def agents_keypair(self): + return self._resources.get('agents_keypair') + + @property + def agents_security_group(self): + return self._resources.get('agents_security_group') + + @property + def ext_network(self): + return self._resources.get('ext_network') + + @property + def floating_ip(self): + return self._resources.get('floating_ip') + + @property + def int_network(self): + return self._resources.get('int_network') + + @property + def management_keypair(self): + return self._resources.get('management_keypair') + + @property + def management_security_group(self): + return self._resources.get('management_security_group') + + @property + def management_server(self): + return self._resources.get('management_server') + + @property + def router(self): + return self._resources.get('router') + + @property + def subnet(self): + return self._resources.get('subnet') + + def __repr__(self): + info = json.dumps(self._provider_context) + return '<' + self.__class__.__name__ + ' ' + info + '>' + + +def provider(ctx): + return ProviderContext(ctx.provider_context) + + +def assign_payload_as_runtime_properties(ctx, resource_name, payload={}): + """ + In general Openstack API objects have create, update, and delete + functions. Each function normally receives a payload that describes + the desired configuration of the object. + This makes sure to store that configuration in the runtime + properties and cleans any potentially sensitive data. + + :param ctx: The Cloudify NodeInstanceContext + :param resource_name: A string describing the resource. + :param payload: The payload. + :return: + """ + + # Avoid failing if a developer inadvertently passes a + # non-NodeInstanceContext + if getattr(ctx, 'instance'): + if resource_name not in ctx.instance.runtime_properties.keys(): + ctx.instance.runtime_properties[resource_name] = {} + for key, value in payload.items(): + if key != 'user_data' and key != 'adminPass': + ctx.instance.runtime_properties[resource_name][key] = value + + +def get_relationships_by_relationship_type(ctx, type_name): + """ + Get cloudify relationships by relationship type. + Follows the inheritance tree. + + :param ctx: Cloudify NodeInstanceContext + :param type_name: desired relationship type derived + from cloudify.relationships.depends_on. + :return: list of RelationshipSubjectContext + """ + + return [rel for rel in ctx.instance.relationships if + type_name in rel.type_hierarchy] + + +def get_attribute_of_connected_nodes_by_relationship_type(ctx, + type_name, + attribute_name): + """ + Returns a list of OPENSTACK_ID_PROPERTY from a list of + Cloudify RelationshipSubjectContext. + + :param ctx: Cloudify NodeInstanceContext + :param type_name: desired relationship type derived + from cloudify.relationships.depends_on. + :param attribute_name: usually either + OPENSTACK_NAME_PROPERTY or OPENSTACK_ID_PROPERTY + :return: + """ + + return [rel.target.instance.runtime_properties[attribute_name] + for rel in get_relationships_by_relationship_type(ctx, type_name)] + + +def get_relationships_by_openstack_type(ctx, type_name): + return [rel for rel in ctx.instance.relationships + if rel.target.instance.runtime_properties.get( + OPENSTACK_TYPE_PROPERTY) == type_name] + + +def get_connected_nodes_by_openstack_type(ctx, type_name): + return [rel.target.node + for rel in get_relationships_by_openstack_type(ctx, type_name)] + + +def get_openstack_ids_of_connected_nodes_by_openstack_type(ctx, type_name): + return [rel.target.instance.runtime_properties[OPENSTACK_ID_PROPERTY] + for rel in get_relationships_by_openstack_type(ctx, type_name) + ] + + +def get_openstack_names_of_connected_nodes_by_openstack_type(ctx, type_name): + return [rel.target.instance.runtime_properties[OPENSTACK_NAME_PROPERTY] + for rel in get_relationships_by_openstack_type(ctx, type_name) + ] + + +def get_single_connected_node_by_openstack_type( + ctx, type_name, if_exists=False): + nodes = get_connected_nodes_by_openstack_type(ctx, type_name) + check = len(nodes) > 1 if if_exists else len(nodes) != 1 + if check: + raise NonRecoverableError( + 'Expected {0} one {1} node. got {2}'.format( + 'at most' if if_exists else 'exactly', type_name, len(nodes))) + return nodes[0] if nodes else None + + +def get_openstack_id_of_single_connected_node_by_openstack_type( + ctx, type_name, if_exists=False): + ids = get_openstack_ids_of_connected_nodes_by_openstack_type(ctx, + type_name) + check = len(ids) > 1 if if_exists else len(ids) != 1 + if check: + raise NonRecoverableError( + 'Expected {0} one {1} capability. got {2}'.format( + 'at most' if if_exists else 'exactly', type_name, len(ids))) + return ids[0] if ids else None + + +def get_resource_id(ctx, type_name): + if ctx.node.properties['resource_id']: + return ctx.node.properties['resource_id'] + return "{0}_{1}_{2}".format(type_name, ctx.deployment.id, ctx.instance.id) + + +def transform_resource_name(ctx, res): + + if isinstance(res, basestring): + res = {'name': res} + + if not isinstance(res, dict): + raise ValueError("transform_resource_name() expects either string or " + "dict as the first parameter") + + pfx = ctx.bootstrap_context.resources_prefix + + if not pfx: + return res['name'] + + name = res['name'] + res['name'] = pfx + name + + if name.startswith(pfx): + ctx.logger.warn("Prefixing resource '{0}' with '{1}' but it " + "already has this prefix".format(name, pfx)) + else: + ctx.logger.info("Transformed resource name '{0}' to '{1}'".format( + name, res['name'])) + + return res['name'] + + +def _get_resource_by_name_or_id_from_ctx(ctx, name_field_name, openstack_type, + sugared_client): + resource_id = ctx.node.properties['resource_id'] + if not resource_id: + raise NonRecoverableError( + "Can't set '{0}' to True without supplying a value for " + "'resource_id'".format(USE_EXTERNAL_RESOURCE_PROPERTY)) + + return get_resource_by_name_or_id(resource_id, openstack_type, + sugared_client, True, name_field_name) + + +def get_resource_by_name_or_id( + resource_id, openstack_type, sugared_client, + raise_if_not_found=True, name_field_name='name'): + + # search for resource by name (or name-equivalent field) + search_param = {name_field_name: resource_id} + resource = sugared_client.cosmo_get_if_exists(openstack_type, + **search_param) + if not resource: + # fallback - search for resource by id + resource = sugared_client.cosmo_get_if_exists( + openstack_type, id=resource_id) + + if not resource and raise_if_not_found: + raise NonRecoverableError( + MISSING_RESOURCE_MESSAGE.format(openstack_type, resource_id)) + + return resource + + +def use_external_resource(ctx, sugared_client, openstack_type, + name_field_name='name'): + if not is_external_resource(ctx): + return None + try: + resource = _get_resource_by_name_or_id_from_ctx( + ctx, name_field_name, openstack_type, sugared_client) + except NonRecoverableError: + if is_create_if_missing(ctx): + ctx.instance.runtime_properties[CONDITIONALLY_CREATED] = True + return None + else: + raise + + ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY] = \ + sugared_client.get_id_from_resource(resource) + ctx.instance.runtime_properties[OPENSTACK_TYPE_PROPERTY] = openstack_type + + from openstack_plugin_common.floatingip import FLOATINGIP_OPENSTACK_TYPE + # store openstack name runtime property, unless it's a floating IP type, + # in which case the ip will be stored in the runtime properties instead. + if openstack_type != FLOATINGIP_OPENSTACK_TYPE: + ctx.instance.runtime_properties[OPENSTACK_NAME_PROPERTY] = \ + sugared_client.get_name_from_resource(resource) + + ctx.logger.info('Using external resource {0}: {1}'.format( + openstack_type, ctx.node.properties['resource_id'])) + return resource + + +def validate_resource(ctx, sugared_client, openstack_type, + name_field_name='name'): + ctx.logger.debug('validating resource {0} (node {1})'.format( + openstack_type, ctx.node.id)) + + openstack_type_plural = sugared_client.cosmo_plural(openstack_type) + resource = None + + if is_external_resource(ctx): + + try: + # validate the resource truly exists + resource = _get_resource_by_name_or_id_from_ctx( + ctx, name_field_name, openstack_type, sugared_client) + ctx.logger.debug('OK: {0} {1} found in pool'.format( + openstack_type, ctx.node.properties['resource_id'])) + except NonRecoverableError as e: + if not is_create_if_missing(ctx): + ctx.logger.error('VALIDATION ERROR: ' + str(e)) + resource_list = list(sugared_client.cosmo_list(openstack_type)) + if resource_list: + ctx.logger.info('list of existing {0}: '.format( + openstack_type_plural)) + for resource in resource_list: + ctx.logger.info(' {0:>10} - {1}'.format( + sugared_client.get_id_from_resource(resource), + sugared_client.get_name_from_resource(resource))) + else: + ctx.logger.info('there are no existing {0}'.format( + openstack_type_plural)) + raise + if not resource: + if isinstance(sugared_client, NovaClientWithSugar): + # not checking quota for Nova resources due to a bug in Nova client + return + + # validate available quota for provisioning the resource + resource_list = list(sugared_client.cosmo_list(openstack_type)) + resource_amount = len(resource_list) + + resource_quota = sugared_client.get_quota(openstack_type) + + if resource_amount < resource_quota \ + or resource_quota == INFINITE_RESOURCE_QUOTA: + ctx.logger.debug( + 'OK: {0} (node {1}) can be created. provisioned {2}: {3}, ' + 'quota: {4}' + .format(openstack_type, ctx.node.id, openstack_type_plural, + resource_amount, resource_quota)) + else: + err = ('{0} (node {1}) cannot be created due to quota limitations.' + ' provisioned {2}: {3}, quota: {4}' + .format(openstack_type, ctx.node.id, openstack_type_plural, + resource_amount, resource_quota)) + ctx.logger.error('VALIDATION ERROR:' + err) + raise NonRecoverableError(err) + + +def delete_resource_and_runtime_properties(ctx, sugared_client, + runtime_properties_keys): + node_openstack_type = ctx.instance.runtime_properties[ + OPENSTACK_TYPE_PROPERTY] + if not is_external_resource(ctx): + ctx.logger.info('deleting {0}'.format(node_openstack_type)) + sugared_client.cosmo_delete_resource( + node_openstack_type, + ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY]) + else: + ctx.logger.info('not deleting {0} since an external {0} is ' + 'being used'.format(node_openstack_type)) + + delete_runtime_properties(ctx, runtime_properties_keys) + + +def is_external_resource(ctx): + return is_external_resource_by_properties(ctx.node.properties) + + +def is_external_resource_not_conditionally_created(ctx): + return is_external_resource_by_properties(ctx.node.properties) and \ + not ctx.instance.runtime_properties.get(CONDITIONALLY_CREATED) + + +def is_external_relationship_not_conditionally_created(ctx): + return is_external_resource_by_properties(ctx.source.node.properties) and \ + is_external_resource_by_properties(ctx.target.node.properties) and \ + not ctx.source.instance.runtime_properties.get( + CONDITIONALLY_CREATED) and not \ + ctx.target.instance.runtime_properties.get(CONDITIONALLY_CREATED) + + +def is_create_if_missing(ctx): + return is_create_if_missing_by_properties(ctx.node.properties) + + +def is_external_relationship(ctx): + return is_external_resource_by_properties(ctx.source.node.properties) and \ + is_external_resource_by_properties(ctx.target.node.properties) + + +def is_external_resource_by_properties(properties): + return USE_EXTERNAL_RESOURCE_PROPERTY in properties and \ + properties[USE_EXTERNAL_RESOURCE_PROPERTY] + + +def is_create_if_missing_by_properties(properties): + return CREATE_IF_MISSING_PROPERTY in properties and \ + properties[CREATE_IF_MISSING_PROPERTY] + + +def delete_runtime_properties(ctx, runtime_properties_keys): + for runtime_prop_key in runtime_properties_keys: + if runtime_prop_key in ctx.instance.runtime_properties: + del ctx.instance.runtime_properties[runtime_prop_key] + + +def validate_ip_or_range_syntax(ctx, address, is_range=True): + range_suffix = ' range' if is_range else '' + ctx.logger.debug('checking whether {0} is a valid address{1}...' + .format(address, range_suffix)) + try: + IP(address) + ctx.logger.debug('OK:' + '{0} is a valid address{1}.'.format(address, + range_suffix)) + except ValueError as e: + err = ('{0} is not a valid address{1}; {2}'.format( + address, range_suffix, e.message)) + ctx.logger.error('VALIDATION ERROR:' + err) + raise NonRecoverableError(err) + + +class Config(object): + + OPENSTACK_CONFIG_PATH_ENV_VAR = 'OPENSTACK_CONFIG_PATH' + OPENSTACK_CONFIG_PATH_DEFAULT_PATH = '~/openstack_config.json' + OPENSTACK_ENV_VAR_PREFIX = 'OS_' + OPENSTACK_SUPPORTED_ENV_VARS = { + 'OS_AUTH_URL', 'OS_USERNAME', 'OS_PASSWORD', 'OS_TENANT_NAME', + 'OS_REGION_NAME', 'OS_PROJECT_ID', 'OS_PROJECT_NAME', + 'OS_USER_DOMAIN_NAME', 'OS_PROJECT_DOMAIN_NAME' + } + + @classmethod + def get(cls): + static_config = cls._build_config_from_env_variables() + env_name = cls.OPENSTACK_CONFIG_PATH_ENV_VAR + default_location_tpl = cls.OPENSTACK_CONFIG_PATH_DEFAULT_PATH + default_location = os.path.expanduser(default_location_tpl) + config_path = os.getenv(env_name, default_location) + try: + with open(config_path) as f: + cls.update_config(static_config, json.loads(f.read())) + except IOError: + pass + return static_config + + @classmethod + def _build_config_from_env_variables(cls): + return {v.lstrip(cls.OPENSTACK_ENV_VAR_PREFIX).lower(): os.environ[v] + for v in cls.OPENSTACK_SUPPORTED_ENV_VARS if v in os.environ} + + @staticmethod + def update_config(overridden_cfg, overriding_cfg): + """ this method is like dict.update() only that it doesn't override + with (or set new) empty values (e.g. empty string) """ + for k, v in overriding_cfg.iteritems(): + if v: + overridden_cfg[k] = v + + +class OpenStackClient(object): + + COMMON = {'username', 'password', 'auth_url'} + AUTH_SETS = [ + COMMON | {'tenant_name'}, + COMMON | {'project_id', 'user_domain_name'}, + COMMON | {'project_id', 'project_name', 'user_domain_name'}, + COMMON | {'project_name', 'user_domain_name', 'project_domain_name'}, + ] + OPTIONAL_AUTH_PARAMS = {'insecure'} + + def __init__(self, client_name, client_class, config=None, *args, **kw): + cfg = Config.get() + + if config: + Config.update_config(cfg, config) + + v3 = '/v3' in cfg['auth_url'] + # Newer libraries expect the region key to be `region_name`, not + # `region`. + region = cfg.pop('region', None) + if v3 and region: + cfg['region_name'] = region + + cfg = self._merge_custom_configuration(cfg, client_name) + + auth_params, client_params = OpenStackClient._split_config(cfg) + OpenStackClient._validate_auth_params(auth_params) + + if v3: + # keystone v3 complains if these aren't set. + for key in 'user_domain_name', 'project_domain_name': + auth_params.setdefault(key, 'default') + + client_params['session'] = self._authenticate(auth_params) + self._client = client_class(**client_params) + + @classmethod + def _validate_auth_params(cls, params): + if set(params.keys()) - cls.OPTIONAL_AUTH_PARAMS in cls.AUTH_SETS: + return + + def set2str(s): + return '({})'.format(', '.join(sorted(s))) + + received_params = set2str(params) + valid_auth_sets = map(set2str, cls.AUTH_SETS) + raise NonRecoverableError( + "{} is not valid set of auth params. Expected to find parameters " + "either as environment variables, in a JSON file (at either a " + "path which is set under the environment variable {} or at the " + "default location {}), or as nested properties under an " + "'{}' property. Valid auth param sets are: {}." + .format(received_params, + Config.OPENSTACK_CONFIG_PATH_ENV_VAR, + Config.OPENSTACK_CONFIG_PATH_DEFAULT_PATH, + CONFIG_PROPERTY, + ', '.join(valid_auth_sets))) + + @staticmethod + def _merge_custom_configuration(cfg, client_name): + config = cfg.copy() + + mapping = { + 'nova_url': 'nova_client', + 'neutron_url': 'neutron_client' + } + for key in 'nova_url', 'neutron_url': + val = config.pop(key, None) + if val is not None: + ctx.logger.warn( + "'{}' property is deprecated. Use `custom_configuration" + ".{}.endpoint_override` instead.".format( + key, mapping[key])) + if mapping.get(key, None) == client_name: + config['endpoint_override'] = val + + if 'custom_configuration' in cfg: + del config['custom_configuration'] + config.update(cfg['custom_configuration'].get(client_name, {})) + return config + + @classmethod + def _split_config(cls, cfg): + all = reduce(lambda x, y: x | y, cls.AUTH_SETS) + all |= cls.OPTIONAL_AUTH_PARAMS + + auth, misc = {}, {} + for param, value in cfg.items(): + if param in all: + auth[param] = value + else: + misc[param] = value + return auth, misc + + @staticmethod + def _authenticate(cfg): + verify = True + if 'insecure' in cfg: + cfg = cfg.copy() + # NOTE: Next line will evaluate to False only when insecure is set + # to True. Any other value (string etc.) will force verify to True. + # This is done on purpose, since we do not wish to use insecure + # connection by mistake. + verify = not (cfg['insecure'] is True) + del cfg['insecure'] + loader = loading.get_plugin_loader("password") + auth = loader.load_from_options(**cfg) + sess = session.Session(auth=auth, verify=verify) + return sess + + # Proxy any unknown call to base client + def __getattr__(self, attr): + return getattr(self._client, attr) + + # Sugar, common to all clients + def cosmo_plural(self, obj_type_single): + return obj_type_single + 's' + + def cosmo_get_named(self, obj_type_single, name, **kw): + return self.cosmo_get(obj_type_single, name=name, **kw) + + def cosmo_get(self, obj_type_single, **kw): + return self._cosmo_get(obj_type_single, False, **kw) + + def cosmo_get_if_exists(self, obj_type_single, **kw): + return self._cosmo_get(obj_type_single, True, **kw) + + def _cosmo_get(self, obj_type_single, if_exists, **kw): + ls = list(self.cosmo_list(obj_type_single, **kw)) + check = len(ls) > 1 if if_exists else len(ls) != 1 + if check: + raise NonRecoverableError( + "Expected {0} one object of type {1} " + "with match {2} but there are {3}".format( + 'at most' if if_exists else 'exactly', + obj_type_single, kw, len(ls))) + return ls[0] if ls else None + + +class GlanceClient(OpenStackClient): + + # Can't glance_url be figured out from keystone + REQUIRED_CONFIG_PARAMS = \ + ['username', 'password', 'tenant_name', 'auth_url'] + + def connect(self, cfg): + loader = loading.get_plugin_loader('password') + auth = loader.load_from_options( + auth_url=cfg['auth_url'], + username=cfg['username'], + password=cfg['password'], + tenant_name=cfg['tenant_name']) + sess = session.Session(auth=auth) + + client_kwargs = dict( + session=sess, + ) + if cfg.get('glance_url'): + client_kwargs['endpoint'] = cfg['glance_url'] + + return GlanceClientWithSugar(**client_kwargs) + + +# Decorators +def _find_instanceof_in_kw(cls, kw): + ret = [v for v in kw.values() if isinstance(v, cls)] + if not ret: + return None + if len(ret) > 1: + raise NonRecoverableError( + "Expected to find exactly one instance of {0} in " + "kwargs but found {1}".format(cls, len(ret))) + return ret[0] + + +def _find_context_in_kw(kw): + return _find_instanceof_in_kw(cloudify.context.CloudifyContext, kw) + + +def with_neutron_client(f): + @wraps(f) + def wrapper(*args, **kw): + _put_client_in_kw('neutron_client', NeutronClientWithSugar, kw) + + try: + return f(*args, **kw) + except neutron_exceptions.NeutronClientException, e: + if e.status_code in _non_recoverable_error_codes: + _re_raise(e, recoverable=False, status_code=e.status_code) + else: + raise + return wrapper + + +def with_nova_client(f): + @wraps(f) + def wrapper(*args, **kw): + _put_client_in_kw('nova_client', NovaClientWithSugar, kw) + + try: + return f(*args, **kw) + except nova_exceptions.OverLimit, e: + _re_raise(e, recoverable=True, retry_after=e.retry_after) + except nova_exceptions.ClientException, e: + if e.code in _non_recoverable_error_codes: + _re_raise(e, recoverable=False, status_code=e.code) + else: + raise + return wrapper + + +def with_cinder_client(f): + @wraps(f) + def wrapper(*args, **kw): + _put_client_in_kw('cinder_client', CinderClientWithSugar, kw) + + try: + return f(*args, **kw) + except cinder_exceptions.ClientException, e: + if e.code in _non_recoverable_error_codes: + _re_raise(e, recoverable=False, status_code=e.code) + else: + raise + return wrapper + + +def with_glance_client(f): + @wraps(f) + def wrapper(*args, **kw): + _put_client_in_kw('glance_client', GlanceClientWithSugar, kw) + + try: + return f(*args, **kw) + except glance_exceptions.ClientException, e: + if e.code in _non_recoverable_error_codes: + _re_raise(e, recoverable=False, status_code=e.code) + else: + raise + return wrapper + + +def with_keystone_client(f): + @wraps(f) + def wrapper(*args, **kw): + _put_client_in_kw('keystone_client', KeystoneClientWithSugar, kw) + + try: + return f(*args, **kw) + except keystone_exceptions.HTTPError, e: + if e.http_status in _non_recoverable_error_codes: + _re_raise(e, recoverable=False, status_code=e.http_status) + else: + raise + except keystone_exceptions.ClientException, e: + _re_raise(e, recoverable=False) + return wrapper + + +def _put_client_in_kw(client_name, client_class, kw): + if client_name in kw: + return + + ctx = _find_context_in_kw(kw) + if ctx.type == context.NODE_INSTANCE: + config = ctx.node.properties.get(CONFIG_PROPERTY) + rt_config = ctx.instance.runtime_properties.get( + CONFIG_RUNTIME_PROPERTY) + elif ctx.type == context.RELATIONSHIP_INSTANCE: + config = ctx.source.node.properties.get(CONFIG_PROPERTY) + rt_config = ctx.source.instance.runtime_properties.get( + CONFIG_RUNTIME_PROPERTY) + if not config: + config = ctx.target.node.properties.get(CONFIG_PROPERTY) + rt_config = ctx.target.instance.runtime_properties.get( + CONFIG_RUNTIME_PROPERTY) + + else: + config = None + rt_config = None + + # Overlay with configuration from runtime property, if any. + if rt_config: + if config: + config = config.copy() + config.update(rt_config) + else: + config = rt_config + + if CONFIG_INPUT in kw: + if config: + config = config.copy() + config.update(kw[CONFIG_INPUT]) + else: + config = kw[CONFIG_INPUT] + kw[client_name] = client_class(config=config) + + +_non_recoverable_error_codes = [400, 401, 403, 404, 409] + + +def _re_raise(e, recoverable, retry_after=None, status_code=None): + exc_type, exc, traceback = sys.exc_info() + message = e.message + if status_code is not None: + message = '{0} [status_code={1}]'.format(message, status_code) + if recoverable: + if retry_after == 0: + retry_after = None + raise RecoverableError( + message=message, + retry_after=retry_after), None, traceback + else: + raise NonRecoverableError(message), None, traceback + + +# Sugar for clients + +class NovaClientWithSugar(OpenStackClient): + + def __init__(self, *args, **kw): + config = kw['config'] + if config.get('nova_url'): + config['endpoint_override'] = config.pop('nova_url') + + super(NovaClientWithSugar, self).__init__( + 'nova_client', partial(nova_client.Client, '2'), *args, **kw) + + def cosmo_list(self, obj_type_single, **kw): + """ Sugar for xxx.findall() - not using xxx.list() because findall + can receive filtering parameters, and it's common for all types""" + obj_type_plural = self._get_nova_field_name_for_type(obj_type_single) + for obj in getattr(self, obj_type_plural).findall(**kw): + yield obj + + def cosmo_delete_resource(self, obj_type_single, obj_id): + obj_type_plural = self._get_nova_field_name_for_type(obj_type_single) + getattr(self, obj_type_plural).delete(obj_id) + + def get_id_from_resource(self, resource): + return resource.id + + def get_name_from_resource(self, resource): + return resource.name + + def get_quota(self, obj_type_single): + raise RuntimeError( + 'Retrieving quotas from Nova service is currently unsupported ' + 'due to a bug in Nova python client') + + # we're already authenticated, but the following call will make + # 'service_catalog' available under 'client', through which we can + # extract the tenant_id (Note that self.client.tenant_id might be + # None if project_id (AKA tenant_name) was used instead; However the + # actual tenant_id must be used to retrieve the quotas) + self.client.authenticate() + tenant_id = self.client.service_catalog.get_tenant_id() + quotas = self.quotas.get(tenant_id) + return getattr(quotas, self.cosmo_plural(obj_type_single)) + + def _get_nova_field_name_for_type(self, obj_type_single): + from openstack_plugin_common.floatingip import \ + FLOATINGIP_OPENSTACK_TYPE + if obj_type_single == FLOATINGIP_OPENSTACK_TYPE: + # since we use the same 'openstack type' property value for both + # neutron and nova floating-ips, this adjustment must be made + # for nova client, as fields names differ between the two clients + obj_type_single = 'floating_ip' + return self.cosmo_plural(obj_type_single) + + +class NeutronClientWithSugar(OpenStackClient): + + def __init__(self, *args, **kw): + super(NeutronClientWithSugar, self).__init__( + 'neutron_client', neutron_client.Client, *args, **kw) + + def cosmo_list(self, obj_type_single, **kw): + """ Sugar for list_XXXs()['XXXs'] """ + obj_type_plural = self.cosmo_plural(obj_type_single) + for obj in getattr(self, 'list_' + obj_type_plural)(**kw)[ + obj_type_plural]: + yield obj + + def cosmo_delete_resource(self, obj_type_single, obj_id): + getattr(self, 'delete_' + obj_type_single)(obj_id) + + def get_id_from_resource(self, resource): + return resource['id'] + + def get_name_from_resource(self, resource): + return resource['name'] + + def get_quota(self, obj_type_single): + tenant_id = self.get_quotas_tenant()['tenant']['tenant_id'] + quotas = self.show_quota(tenant_id)['quota'] + return quotas[obj_type_single] + + def cosmo_list_prefixed(self, obj_type_single, name_prefix): + for obj in self.cosmo_list(obj_type_single): + if obj['name'].startswith(name_prefix): + yield obj + + def cosmo_delete_prefixed(self, name_prefix): + # Cleanup all neutron.list_XXX() objects with names starting + # with self.name_prefix + for obj_type_single in 'port', 'router', 'network', 'subnet',\ + 'security_group': + for obj in self.cosmo_list_prefixed(obj_type_single, name_prefix): + if obj_type_single == 'router': + ports = self.cosmo_list('port', device_id=obj['id']) + for port in ports: + try: + self.remove_interface_router( + port['device_id'], + {'port_id': port['id']}) + except neutron_exceptions.NeutronClientException: + pass + getattr(self, 'delete_' + obj_type_single)(obj['id']) + + def cosmo_find_external_net(self): + """ For tests of floating IP """ + nets = self.list_networks()['networks'] + ls = [net for net in nets if net.get('router:external')] + if len(ls) != 1: + raise NonRecoverableError( + "Expected exactly one external network but found {0}".format( + len(ls))) + return ls[0] + + +class CinderClientWithSugar(OpenStackClient): + + def __init__(self, *args, **kw): + super(CinderClientWithSugar, self).__init__( + 'cinder_client', partial(cinder_client.Client, '2'), *args, **kw) + + def cosmo_list(self, obj_type_single, **kw): + obj_type_plural = self.cosmo_plural(obj_type_single) + for obj in getattr(self, obj_type_plural).findall(**kw): + yield obj + + def cosmo_delete_resource(self, obj_type_single, obj_id): + obj_type_plural = self.cosmo_plural(obj_type_single) + getattr(self, obj_type_plural).delete(obj_id) + + def get_id_from_resource(self, resource): + return resource.id + + def get_name_from_resource(self, resource): + return resource.name + + def get_quota(self, obj_type_single): + # we're already authenticated, but the following call will make + # 'service_catalog' available under 'client', through which we can + # extract the tenant_id (Note that self.client.tenant_id might be + # None if project_id (AKA tenant_name) was used instead; However the + # actual tenant_id must be used to retrieve the quotas) + self.client.authenticate() + project_id = self.client.session.get_project_id() + quotas = self.quotas.get(project_id) + return getattr(quotas, self.cosmo_plural(obj_type_single)) + + +class KeystoneClientWithSugar(OpenStackClient): + # keystone does not have resource quota + KEYSTONE_INFINITE_RESOURCE_QUOTA = 10**9 + + def __init__(self, *args, **kw): + super(KeystoneClientWithSugar, self).__init__( + 'keystone_client', keystone_client.Client, *args, **kw) + + def cosmo_list(self, obj_type_single, **kw): + obj_type_plural = self.cosmo_plural(obj_type_single) + for obj in getattr(self, obj_type_plural).list(**kw): + yield obj + + def cosmo_delete_resource(self, obj_type_single, obj_id): + obj_type_plural = self.cosmo_plural(obj_type_single) + getattr(self, obj_type_plural).delete(obj_id) + + def get_id_from_resource(self, resource): + return resource.id + + def get_name_from_resource(self, resource): + return resource.name + + def get_quota(self, obj_type_single): + return self.KEYSTONE_INFINITE_RESOURCE_QUOTA + + +class GlanceClientWithSugar(OpenStackClient): + GLANCE_INIFINITE_RESOURCE_QUOTA = 10**9 + + def __init__(self, *args, **kw): + super(GlanceClientWithSugar, self).__init__( + 'glance_client', partial(glance_client.Client, '2'), *args, **kw) + + def cosmo_list(self, obj_type_single, **kw): + obj_type_plural = self.cosmo_plural(obj_type_single) + return getattr(self, obj_type_plural).list(filters=kw) + + def cosmo_delete_resource(self, obj_type_single, obj_id): + obj_type_plural = self.cosmo_plural(obj_type_single) + getattr(self, obj_type_plural).delete(obj_id) + + def get_id_from_resource(self, resource): + return resource.id + + def get_name_from_resource(self, resource): + return resource.name + + def get_quota(self, obj_type_single): + return self.GLANCE_INIFINITE_RESOURCE_QUOTA diff --git a/aria/multivim-plugin/openstack_plugin_common/floatingip.py b/aria/multivim-plugin/openstack_plugin_common/floatingip.py new file mode 100644 index 0000000000..fe5896520b --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/floatingip.py @@ -0,0 +1,84 @@ +######### +# Copyright (c) 2014 GigaSpaces Technologies Ltd. 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 cloudify import ctx +from openstack_plugin_common import ( + delete_resource_and_runtime_properties, + use_external_resource, + validate_resource, + COMMON_RUNTIME_PROPERTIES_KEYS, + OPENSTACK_ID_PROPERTY, + OPENSTACK_TYPE_PROPERTY) + + +FLOATINGIP_OPENSTACK_TYPE = 'floatingip' + +# Runtime properties +IP_ADDRESS_PROPERTY = 'floating_ip_address' # the actual ip address +RUNTIME_PROPERTIES_KEYS = COMMON_RUNTIME_PROPERTIES_KEYS + \ + [IP_ADDRESS_PROPERTY] + + +def use_external_floatingip(client, ip_field_name, ext_fip_ip_extractor): + external_fip = use_external_resource( + ctx, client, FLOATINGIP_OPENSTACK_TYPE, ip_field_name) + if external_fip: + ctx.instance.runtime_properties[IP_ADDRESS_PROPERTY] = \ + ext_fip_ip_extractor(external_fip) + return True + + return False + + +def set_floatingip_runtime_properties(fip_id, ip_address): + ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY] = fip_id + ctx.instance.runtime_properties[OPENSTACK_TYPE_PROPERTY] = \ + FLOATINGIP_OPENSTACK_TYPE + ctx.instance.runtime_properties[IP_ADDRESS_PROPERTY] = ip_address + + +def delete_floatingip(client, **kwargs): + delete_resource_and_runtime_properties(ctx, client, + RUNTIME_PROPERTIES_KEYS) + + +def floatingip_creation_validation(client, ip_field_name, **kwargs): + validate_resource(ctx, client, FLOATINGIP_OPENSTACK_TYPE, + ip_field_name) + + +def get_server_floating_ip(neutron_client, server_id): + + floating_ips = neutron_client.list_floatingips() + + floating_ips = floating_ips.get('floatingips') + if not floating_ips: + return None + + for floating_ip in floating_ips: + port_id = floating_ip.get('port_id') + if not port_id: + # this floating ip is not attached to any port + continue + + port = neutron_client.show_port(port_id)['port'] + device_id = port.get('device_id') + if not device_id: + # this port is not attached to any server + continue + + if server_id == device_id: + return floating_ip + return None diff --git a/aria/multivim-plugin/openstack_plugin_common/security_group.py b/aria/multivim-plugin/openstack_plugin_common/security_group.py new file mode 100644 index 0000000000..0fa21aa149 --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/security_group.py @@ -0,0 +1,148 @@ +######### +# Copyright (c) 2014 GigaSpaces Technologies Ltd. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. + +import copy +import re + +from cloudify import ctx +from cloudify.exceptions import NonRecoverableError + +from openstack_plugin_common import ( + get_resource_id, + use_external_resource, + delete_resource_and_runtime_properties, + validate_resource, + validate_ip_or_range_syntax, + OPENSTACK_ID_PROPERTY, + OPENSTACK_TYPE_PROPERTY, + OPENSTACK_NAME_PROPERTY, + COMMON_RUNTIME_PROPERTIES_KEYS +) + +SECURITY_GROUP_OPENSTACK_TYPE = 'security_group' + +# Runtime properties +RUNTIME_PROPERTIES_KEYS = COMMON_RUNTIME_PROPERTIES_KEYS + +NODE_NAME_RE = re.compile('^(.*)_.*$') # Anything before last underscore + + +def build_sg_data(args=None): + security_group = { + 'description': None, + 'name': get_resource_id(ctx, SECURITY_GROUP_OPENSTACK_TYPE), + } + + args = args or {} + security_group.update(ctx.node.properties['security_group'], **args) + + return security_group + + +def process_rules(client, sgr_default_values, cidr_field_name, + remote_group_field_name, min_port_field_name, + max_port_field_name): + rules_to_apply = ctx.node.properties['rules'] + security_group_rules = [] + for rule in rules_to_apply: + security_group_rules.append( + _process_rule(rule, client, sgr_default_values, cidr_field_name, + remote_group_field_name, min_port_field_name, + max_port_field_name)) + + return security_group_rules + + +def use_external_sg(client): + return use_external_resource(ctx, client, + SECURITY_GROUP_OPENSTACK_TYPE) + + +def set_sg_runtime_properties(sg, client): + ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY] =\ + client.get_id_from_resource(sg) + ctx.instance.runtime_properties[OPENSTACK_TYPE_PROPERTY] =\ + SECURITY_GROUP_OPENSTACK_TYPE + ctx.instance.runtime_properties[OPENSTACK_NAME_PROPERTY] = \ + client.get_name_from_resource(sg) + + +def delete_sg(client, **kwargs): + delete_resource_and_runtime_properties(ctx, client, + RUNTIME_PROPERTIES_KEYS) + + +def sg_creation_validation(client, cidr_field_name, **kwargs): + validate_resource(ctx, client, SECURITY_GROUP_OPENSTACK_TYPE) + + ctx.logger.debug('validating CIDR for rules with a {0} field'.format( + cidr_field_name)) + for rule in ctx.node.properties['rules']: + if cidr_field_name in rule: + validate_ip_or_range_syntax(ctx, rule[cidr_field_name]) + + +def _process_rule(rule, client, sgr_default_values, cidr_field_name, + remote_group_field_name, min_port_field_name, + max_port_field_name): + ctx.logger.debug( + "Security group rule before transformations: {0}".format(rule)) + + sgr = copy.deepcopy(sgr_default_values) + if 'port' in rule: + rule[min_port_field_name] = rule['port'] + rule[max_port_field_name] = rule['port'] + del rule['port'] + sgr.update(rule) + + if (remote_group_field_name in sgr) and sgr[remote_group_field_name]: + sgr[cidr_field_name] = None + elif ('remote_group_node' in sgr) and sgr['remote_group_node']: + _, remote_group_node = _capabilities_of_node_named( + sgr['remote_group_node']) + sgr[remote_group_field_name] = remote_group_node[OPENSTACK_ID_PROPERTY] + del sgr['remote_group_node'] + sgr[cidr_field_name] = None + elif ('remote_group_name' in sgr) and sgr['remote_group_name']: + sgr[remote_group_field_name] = \ + client.get_id_from_resource( + client.cosmo_get_named( + SECURITY_GROUP_OPENSTACK_TYPE, sgr['remote_group_name'])) + del sgr['remote_group_name'] + sgr[cidr_field_name] = None + + ctx.logger.debug( + "Security group rule after transformations: {0}".format(sgr)) + return sgr + + +def _capabilities_of_node_named(node_name): + result = None + caps = ctx.capabilities.get_all() + for node_id in caps: + match = NODE_NAME_RE.match(node_id) + if match: + candidate_node_name = match.group(1) + if candidate_node_name == node_name: + if result: + raise NonRecoverableError( + "More than one node named '{0}' " + "in capabilities".format(node_name)) + result = (node_id, caps[node_id]) + if not result: + raise NonRecoverableError( + "Could not find node named '{0}' " + "in capabilities".format(node_name)) + return result diff --git a/aria/multivim-plugin/openstack_plugin_common/tests/__init__.py b/aria/multivim-plugin/openstack_plugin_common/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/tests/__init__.py diff --git a/aria/multivim-plugin/openstack_plugin_common/tests/openstack_client_tests.py b/aria/multivim-plugin/openstack_plugin_common/tests/openstack_client_tests.py new file mode 100644 index 0000000000..27d443c2e4 --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/tests/openstack_client_tests.py @@ -0,0 +1,849 @@ +######## +# Copyright (c) 2014 GigaSpaces Technologies Ltd. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. + +import os +import unittest +import tempfile +import json +import __builtin__ as builtins + +import mock +from cloudify.exceptions import NonRecoverableError + +from cloudify.mocks import MockCloudifyContext +import openstack_plugin_common as common + + +class ConfigTests(unittest.TestCase): + + @mock.patch.dict('os.environ', clear=True) + def test__build_config_from_env_variables_empty(self): + cfg = common.Config._build_config_from_env_variables() + self.assertEqual({}, cfg) + + @mock.patch.dict('os.environ', clear=True, + OS_AUTH_URL='test_url') + def test__build_config_from_env_variables_single(self): + cfg = common.Config._build_config_from_env_variables() + self.assertEqual({'auth_url': 'test_url'}, cfg) + + @mock.patch.dict('os.environ', clear=True, + OS_AUTH_URL='test_url', + OS_PASSWORD='pass', + OS_REGION_NAME='region') + def test__build_config_from_env_variables_multiple(self): + cfg = common.Config._build_config_from_env_variables() + self.assertEqual({ + 'auth_url': 'test_url', + 'password': 'pass', + 'region_name': 'region', + }, cfg) + + @mock.patch.dict('os.environ', clear=True, + OS_INVALID='invalid', + PASSWORD='pass', + os_region_name='region') + def test__build_config_from_env_variables_all_ignored(self): + cfg = common.Config._build_config_from_env_variables() + self.assertEqual({}, cfg) + + @mock.patch.dict('os.environ', clear=True, + OS_AUTH_URL='test_url', + OS_PASSWORD='pass', + OS_REGION_NAME='region', + OS_INVALID='invalid', + PASSWORD='pass', + os_region_name='region') + def test__build_config_from_env_variables_extract_valid(self): + cfg = common.Config._build_config_from_env_variables() + self.assertEqual({ + 'auth_url': 'test_url', + 'password': 'pass', + 'region_name': 'region', + }, cfg) + + def test_update_config_empty_target(self): + target = {} + override = {'k1': 'u1'} + result = override.copy() + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + def test_update_config_empty_override(self): + target = {'k1': 'v1'} + override = {} + result = target.copy() + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + def test_update_config_disjoint_configs(self): + target = {'k1': 'v1'} + override = {'k2': 'u2'} + result = target.copy() + result.update(override) + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + def test_update_config_do_not_remove_empty_from_target(self): + target = {'k1': ''} + override = {} + result = target.copy() + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + def test_update_config_no_empty_in_override(self): + target = {'k1': 'v1', 'k2': 'v2'} + override = {'k1': 'u2'} + result = target.copy() + result.update(override) + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + def test_update_config_all_empty_in_override(self): + target = {'k1': '', 'k2': 'v2'} + override = {'k1': '', 'k3': ''} + result = target.copy() + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + def test_update_config_misc(self): + target = {'k1': 'v1', 'k2': 'v2'} + override = {'k1': '', 'k2': 'u2', 'k3': '', 'k4': 'u4'} + result = {'k1': 'v1', 'k2': 'u2', 'k4': 'u4'} + + common.Config.update_config(target, override) + self.assertEqual(result, target) + + @mock.patch.object(common.Config, 'update_config') + @mock.patch.object(common.Config, '_build_config_from_env_variables', + return_value={}) + @mock.patch.dict('os.environ', clear=True, + values={common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR: + '/this/should/not/exist.json'}) + def test_get_missing_static_config_missing_file(self, from_env, update): + cfg = common.Config.get() + self.assertEqual({}, cfg) + from_env.assert_called_once_with() + update.assert_not_called() + + @mock.patch.object(common.Config, 'update_config') + @mock.patch.object(common.Config, '_build_config_from_env_variables', + return_value={}) + def test_get_empty_static_config_present_file(self, from_env, update): + file_cfg = {'k1': 'v1', 'k2': 'v2'} + env_var = common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR + file = tempfile.NamedTemporaryFile(delete=False) + json.dump(file_cfg, file) + file.close() + + with mock.patch.dict('os.environ', {env_var: file.name}, clear=True): + common.Config.get() + + os.unlink(file.name) + from_env.assert_called_once_with() + update.assert_called_once_with({}, file_cfg) + + @mock.patch.object(common.Config, 'update_config') + @mock.patch.object(common.Config, '_build_config_from_env_variables', + return_value={'k1': 'v1'}) + def test_get_present_static_config_empty_file(self, from_env, update): + file_cfg = {} + env_var = common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR + file = tempfile.NamedTemporaryFile(delete=False) + json.dump(file_cfg, file) + file.close() + + with mock.patch.dict('os.environ', {env_var: file.name}, clear=True): + common.Config.get() + + os.unlink(file.name) + from_env.assert_called_once_with() + update.assert_called_once_with({'k1': 'v1'}, file_cfg) + + @mock.patch.object(common.Config, 'update_config') + @mock.patch.object(common.Config, '_build_config_from_env_variables', + return_value={'k1': 'v1'}) + @mock.patch.dict('os.environ', clear=True, + values={common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR: + '/this/should/not/exist.json'}) + def test_get_present_static_config_missing_file(self, from_env, update): + cfg = common.Config.get() + self.assertEqual({'k1': 'v1'}, cfg) + from_env.assert_called_once_with() + update.assert_not_called() + + @mock.patch.object(common.Config, 'update_config') + @mock.patch.object(common.Config, '_build_config_from_env_variables', + return_value={'k1': 'v1'}) + def test_get_all_present(self, from_env, update): + file_cfg = {'k2': 'u2'} + env_var = common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR + file = tempfile.NamedTemporaryFile(delete=False) + json.dump(file_cfg, file) + file.close() + + with mock.patch.dict('os.environ', {env_var: file.name}, clear=True): + common.Config.get() + + os.unlink(file.name) + from_env.assert_called_once_with() + update.assert_called_once_with({'k1': 'v1'}, file_cfg) + + +class OpenstackClientTests(unittest.TestCase): + + def test__merge_custom_configuration_no_custom_cfg(self): + cfg = {'k1': 'v1'} + new = common.OpenStackClient._merge_custom_configuration(cfg, "dummy") + self.assertEqual(cfg, new) + + def test__merge_custom_configuration_client_present(self): + cfg = { + 'k1': 'v1', + 'k2': 'v2', + 'custom_configuration': { + 'dummy': { + 'k2': 'u2', + 'k3': 'u3' + } + } + } + result = { + 'k1': 'v1', + 'k2': 'u2', + 'k3': 'u3' + } + bak = cfg.copy() + new = common.OpenStackClient._merge_custom_configuration(cfg, "dummy") + self.assertEqual(result, new) + self.assertEqual(cfg, bak) + + def test__merge_custom_configuration_client_missing(self): + cfg = { + 'k1': 'v1', + 'k2': 'v2', + 'custom_configuration': { + 'dummy': { + 'k2': 'u2', + 'k3': 'u3' + } + } + } + result = { + 'k1': 'v1', + 'k2': 'v2' + } + bak = cfg.copy() + new = common.OpenStackClient._merge_custom_configuration(cfg, "baddy") + self.assertEqual(result, new) + self.assertEqual(cfg, bak) + + def test__merge_custom_configuration_multi_client(self): + cfg = { + 'k1': 'v1', + 'k2': 'v2', + 'custom_configuration': { + 'dummy': { + 'k2': 'u2', + 'k3': 'u3' + }, + 'bummy': { + 'k1': 'z1' + } + } + } + result = { + 'k1': 'z1', + 'k2': 'v2', + } + bak = cfg.copy() + new = common.OpenStackClient._merge_custom_configuration(cfg, "bummy") + self.assertEqual(result, new) + self.assertEqual(cfg, bak) + + @mock.patch.object(common, 'ctx') + def test__merge_custom_configuration_nova_url(self, mock_ctx): + cfg = { + 'nova_url': 'gopher://nova', + } + bak = cfg.copy() + + self.assertEqual( + common.OpenStackClient._merge_custom_configuration( + cfg, 'nova_client'), + {'endpoint_override': 'gopher://nova'}, + ) + self.assertEqual( + common.OpenStackClient._merge_custom_configuration( + cfg, 'dummy'), + {}, + ) + self.assertEqual(cfg, bak) + mock_ctx.logger.warn.assert_has_calls([ + mock.call( + "'nova_url' property is deprecated. Use `custom_configuration." + "nova_client.endpoint_override` instead."), + mock.call( + "'nova_url' property is deprecated. Use `custom_configuration." + "nova_client.endpoint_override` instead."), + ]) + + @mock.patch('keystoneauth1.session.Session') + def test___init___multi_region(self, m_session): + mock_client_class = mock.MagicMock() + + cfg = { + 'auth_url': 'test-auth_url/v3', + 'region': 'test-region', + } + + with mock.patch.object( + builtins, 'open', + mock.mock_open( + read_data=""" + { + "region": "region from file", + "other": "this one should get through" + } + """ + ), + create=True, + ): + common.OpenStackClient('fred', mock_client_class, cfg) + + mock_client_class.assert_called_once_with( + region_name='test-region', + other='this one should get through', + session=m_session.return_value, + ) + + def test__validate_auth_params_missing(self): + with self.assertRaises(NonRecoverableError): + common.OpenStackClient._validate_auth_params({}) + + def test__validate_auth_params_too_much(self): + with self.assertRaises(NonRecoverableError): + common.OpenStackClient._validate_auth_params({ + 'auth_url': 'url', + 'password': 'pass', + 'username': 'user', + 'tenant_name': 'tenant', + 'project_id': 'project_test', + }) + + def test__validate_auth_params_v2(self): + common.OpenStackClient._validate_auth_params({ + 'auth_url': 'url', + 'password': 'pass', + 'username': 'user', + 'tenant_name': 'tenant', + }) + + def test__validate_auth_params_v3(self): + common.OpenStackClient._validate_auth_params({ + 'auth_url': 'url', + 'password': 'pass', + 'username': 'user', + 'project_id': 'project_test', + 'user_domain_name': 'user_domain', + }) + + def test__validate_auth_params_v3_mod(self): + common.OpenStackClient._validate_auth_params({ + 'auth_url': 'url', + 'password': 'pass', + 'username': 'user', + 'user_domain_name': 'user_domain', + 'project_name': 'project_test_name', + 'project_domain_name': 'project_domain', + }) + + def test__validate_auth_params_skip_insecure(self): + common.OpenStackClient._validate_auth_params({ + 'auth_url': 'url', + 'password': 'pass', + 'username': 'user', + 'user_domain_name': 'user_domain', + 'project_name': 'project_test_name', + 'project_domain_name': 'project_domain', + 'insecure': True + }) + + def test__split_config(self): + auth = {'auth_url': 'url', 'password': 'pass'} + misc = {'misc1': 'val1', 'misc2': 'val2'} + all = dict(auth) + all.update(misc) + + a, m = common.OpenStackClient._split_config(all) + + self.assertEqual(auth, a) + self.assertEqual(misc, m) + + @mock.patch.object(common, 'loading') + @mock.patch.object(common, 'session') + def test__authenticate_secure(self, mock_session, mock_loading): + auth_params = {'k1': 'v1'} + common.OpenStackClient._authenticate(auth_params) + loader = mock_loading.get_plugin_loader.return_value + loader.load_from_options.assert_called_once_with(k1='v1') + auth = loader.load_from_options.return_value + mock_session.Session.assert_called_once_with(auth=auth, verify=True) + + @mock.patch.object(common, 'loading') + @mock.patch.object(common, 'session') + def test__authenticate_secure_explicit(self, mock_session, mock_loading): + auth_params = {'k1': 'v1', 'insecure': False} + common.OpenStackClient._authenticate(auth_params) + loader = mock_loading.get_plugin_loader.return_value + loader.load_from_options.assert_called_once_with(k1='v1') + auth = loader.load_from_options.return_value + mock_session.Session.assert_called_once_with(auth=auth, verify=True) + + @mock.patch.object(common, 'loading') + @mock.patch.object(common, 'session') + def test__authenticate_insecure(self, mock_session, mock_loading): + auth_params = {'k1': 'v1', 'insecure': True} + common.OpenStackClient._authenticate(auth_params) + loader = mock_loading.get_plugin_loader.return_value + loader.load_from_options.assert_called_once_with(k1='v1') + auth = loader.load_from_options.return_value + mock_session.Session.assert_called_once_with(auth=auth, verify=False) + + @mock.patch.object(common, 'loading') + @mock.patch.object(common, 'session') + def test__authenticate_secure_misc(self, mock_session, mock_loading): + params = {'k1': 'v1'} + tests = ('', 'a', [], {}, set(), 4, 0, -1, 3.14, 0.0, None) + for test in tests: + auth_params = params.copy() + auth_params['insecure'] = test + + common.OpenStackClient._authenticate(auth_params) + loader = mock_loading.get_plugin_loader.return_value + loader.load_from_options.assert_called_with(**params) + auth = loader.load_from_options.return_value + mock_session.Session.assert_called_with(auth=auth, verify=True) + + @mock.patch.object(common, 'cinder_client') + def test_cinder_client_get_name_from_resource(self, cc_mock): + ccws = common.CinderClientWithSugar() + mock_volume = mock.Mock() + + self.assertIs( + mock_volume.name, + ccws.get_name_from_resource(mock_volume)) + + +class ClientsConfigTest(unittest.TestCase): + + def setUp(self): + file = tempfile.NamedTemporaryFile(delete=False) + json.dump(self.get_file_cfg(), file) + file.close() + self.addCleanup(os.unlink, file.name) + + env_cfg = self.get_env_cfg() + env_cfg[common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR] = file.name + mock.patch.dict('os.environ', env_cfg, clear=True).start() + + self.loading = mock.patch.object(common, 'loading').start() + self.session = mock.patch.object(common, 'session').start() + self.nova = mock.patch.object(common, 'nova_client').start() + self.neutron = mock.patch.object(common, 'neutron_client').start() + self.cinder = mock.patch.object(common, 'cinder_client').start() + self.addCleanup(mock.patch.stopall) + + self.loader = self.loading.get_plugin_loader.return_value + self.auth = self.loader.load_from_options.return_value + + +class CustomConfigFromInputs(ClientsConfigTest): + + def get_file_cfg(self): + return { + 'username': 'file-username', + 'password': 'file-password', + 'tenant_name': 'file-tenant-name', + 'custom_configuration': { + 'nova_client': { + 'username': 'custom-username', + 'password': 'custom-password', + 'tenant_name': 'custom-tenant-name' + }, + } + } + + def get_inputs_cfg(self): + return { + 'auth_url': 'envar-auth-url', + 'username': 'inputs-username', + 'custom_configuration': { + 'neutron_client': { + 'password': 'inputs-custom-password' + }, + 'cinder_client': { + 'password': 'inputs-custom-password', + 'auth_url': 'inputs-custom-auth-url', + 'extra_key': 'extra-value' + }, + } + } + + def get_env_cfg(self): + return { + 'OS_USERNAME': 'envar-username', + 'OS_PASSWORD': 'envar-password', + 'OS_TENANT_NAME': 'envar-tenant-name', + 'OS_AUTH_URL': 'envar-auth-url', + common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR: file.name + } + + def test_nova(self): + common.NovaClientWithSugar(config=self.get_inputs_cfg()) + self.loader.load_from_options.assert_called_once_with( + username='inputs-username', + password='file-password', + tenant_name='file-tenant-name', + auth_url='envar-auth-url' + ) + self.session.Session.assert_called_with(auth=self.auth, verify=True) + self.nova.Client.assert_called_once_with( + '2', session=self.session.Session.return_value) + + def test_neutron(self): + common.NeutronClientWithSugar(config=self.get_inputs_cfg()) + self.loader.load_from_options.assert_called_once_with( + username='inputs-username', + password='inputs-custom-password', + tenant_name='file-tenant-name', + auth_url='envar-auth-url' + ) + self.session.Session.assert_called_with(auth=self.auth, verify=True) + self.neutron.Client.assert_called_once_with( + session=self.session.Session.return_value) + + def test_cinder(self): + common.CinderClientWithSugar(config=self.get_inputs_cfg()) + self.loader.load_from_options.assert_called_once_with( + username='inputs-username', + password='inputs-custom-password', + tenant_name='file-tenant-name', + auth_url='inputs-custom-auth-url' + ) + self.session.Session.assert_called_with(auth=self.auth, verify=True) + self.cinder.Client.assert_called_once_with( + '2', session=self.session.Session.return_value, + extra_key='extra-value') + + +class CustomConfigFromFile(ClientsConfigTest): + + def get_file_cfg(self): + return { + 'username': 'file-username', + 'password': 'file-password', + 'tenant_name': 'file-tenant-name', + 'custom_configuration': { + 'nova_client': { + 'username': 'custom-username', + 'password': 'custom-password', + 'tenant_name': 'custom-tenant-name' + }, + } + } + + def get_inputs_cfg(self): + return { + 'auth_url': 'envar-auth-url', + 'username': 'inputs-username', + } + + def get_env_cfg(self): + return { + 'OS_USERNAME': 'envar-username', + 'OS_PASSWORD': 'envar-password', + 'OS_TENANT_NAME': 'envar-tenant-name', + 'OS_AUTH_URL': 'envar-auth-url', + common.Config.OPENSTACK_CONFIG_PATH_ENV_VAR: file.name + } + + def test_nova(self): + common.NovaClientWithSugar(config=self.get_inputs_cfg()) + self.loader.load_from_options.assert_called_once_with( + username='custom-username', + password='custom-password', + tenant_name='custom-tenant-name', + auth_url='envar-auth-url' + ) + self.session.Session.assert_called_with(auth=self.auth, verify=True) + self.nova.Client.assert_called_once_with( + '2', session=self.session.Session.return_value) + + def test_neutron(self): + common.NeutronClientWithSugar(config=self.get_inputs_cfg()) + self.loader.load_from_options.assert_called_once_with( + username='inputs-username', + password='file-password', + tenant_name='file-tenant-name', + auth_url='envar-auth-url' + ) + self.session.Session.assert_called_with(auth=self.auth, verify=True) + self.neutron.Client.assert_called_once_with( + session=self.session.Session.return_value) + + def test_cinder(self): + common.CinderClientWithSugar(config=self.get_inputs_cfg()) + self.loader.load_from_options.assert_called_once_with( + username='inputs-username', + password='file-password', + tenant_name='file-tenant-name', + auth_url='envar-auth-url' + ) + self.session.Session.assert_called_with(auth=self.auth, verify=True) + self.cinder.Client.assert_called_once_with( + '2', session=self.session.Session.return_value) + + +class PutClientInKwTests(unittest.TestCase): + + def test_override_prop_empty_ctx(self): + props = {} + ctx = MockCloudifyContext(node_id='a20846', properties=props) + kwargs = { + 'ctx': ctx, + 'openstack_config': { + 'p1': 'v1' + } + } + expected_cfg = kwargs['openstack_config'] + + client_class = mock.MagicMock() + common._put_client_in_kw('mock_client', client_class, kwargs) + client_class.assert_called_once_with(config=expected_cfg) + + def test_override_prop_nonempty_ctx(self): + props = { + 'openstack_config': { + 'p1': 'u1', + 'p2': 'u2' + } + } + props_copy = props.copy() + ctx = MockCloudifyContext(node_id='a20846', properties=props) + kwargs = { + 'ctx': ctx, + 'openstack_config': { + 'p1': 'v1', + 'p3': 'v3' + } + } + expected_cfg = { + 'p1': 'v1', + 'p2': 'u2', + 'p3': 'v3' + } + + client_class = mock.MagicMock() + common._put_client_in_kw('mock_client', client_class, kwargs) + client_class.assert_called_once_with(config=expected_cfg) + # Making sure that _put_client_in_kw will not modify + # 'openstack_config' property of a node. + self.assertEqual(props_copy, ctx.node.properties) + + def test_override_runtime_prop(self): + props = { + 'openstack_config': { + 'p1': 'u1', + 'p2': 'u2' + } + } + runtime_props = { + 'openstack_config': { + 'p1': 'u3' + } + } + props_copy = props.copy() + runtime_props_copy = runtime_props.copy() + ctx = MockCloudifyContext(node_id='a20847', properties=props, + runtime_properties=runtime_props) + kwargs = { + 'ctx': ctx + } + expected_cfg = { + 'p1': 'u3', + 'p2': 'u2' + } + client_class = mock.MagicMock() + common._put_client_in_kw('mock_client', client_class, kwargs) + client_class.assert_called_once_with(config=expected_cfg) + self.assertEqual(props_copy, ctx.node.properties) + self.assertEqual(runtime_props_copy, ctx.instance.runtime_properties) + + +class ResourceQuotaTests(unittest.TestCase): + + def _test_quota_validation(self, amount, quota, failure_expected): + ctx = MockCloudifyContext(node_id='node_id', properties={}) + client = mock.MagicMock() + + def mock_cosmo_list(_): + return [x for x in range(0, amount)] + client.cosmo_list = mock_cosmo_list + + def mock_get_quota(_): + return quota + client.get_quota = mock_get_quota + + if failure_expected: + self.assertRaisesRegexp( + NonRecoverableError, + 'cannot be created due to quota limitations', + common.validate_resource, + ctx=ctx, sugared_client=client, + openstack_type='openstack_type') + else: + common.validate_resource( + ctx=ctx, sugared_client=client, + openstack_type='openstack_type') + + def test_equals_quotas(self): + self._test_quota_validation(3, 3, True) + + def test_exceeded_quota(self): + self._test_quota_validation(5, 3, True) + + def test_infinite_quota(self): + self._test_quota_validation(5, -1, False) + + +class UseExternalResourceTests(unittest.TestCase): + + def _test_use_external_resource(self, + is_external, + create_if_missing, + exists): + properties = {'create_if_missing': create_if_missing, + 'use_external_resource': is_external, + 'resource_id': 'resource_id'} + client_mock = mock.MagicMock() + os_type = 'test' + + def _raise_error(*_): + raise NonRecoverableError('Error') + + def _return_something(*_): + return mock.MagicMock() + + return_value = _return_something if exists else _raise_error + if exists: + properties.update({'resource_id': 'rid'}) + + node_context = MockCloudifyContext(node_id='a20847', + properties=properties) + with mock.patch( + 'openstack_plugin_common._get_resource_by_name_or_id_from_ctx', + new=return_value): + return common.use_external_resource(node_context, + client_mock, os_type) + + def test_use_existing_resource(self): + self.assertIsNotNone(self._test_use_external_resource(True, True, + True)) + self.assertIsNotNone(self._test_use_external_resource(True, False, + True)) + + def test_create_resource(self): + self.assertIsNone(self._test_use_external_resource(False, True, False)) + self.assertIsNone(self._test_use_external_resource(False, False, + False)) + self.assertIsNone(self._test_use_external_resource(True, True, False)) + + def test_raise_error(self): + # If exists and shouldn't it is checked in resource + # validation so below scenario is not tested here + self.assertRaises(NonRecoverableError, + self._test_use_external_resource, + is_external=True, + create_if_missing=False, + exists=False) + + +class ValidateResourceTests(unittest.TestCase): + + def _test_validate_resource(self, + is_external, + create_if_missing, + exists, + client_mock_provided=None): + properties = {'create_if_missing': create_if_missing, + 'use_external_resource': is_external, + 'resource_id': 'resource_id'} + client_mock = client_mock_provided or mock.MagicMock() + os_type = 'test' + + def _raise_error(*_): + raise NonRecoverableError('Error') + + def _return_something(*_): + return mock.MagicMock() + return_value = _return_something if exists else _raise_error + if exists: + properties.update({'resource_id': 'rid'}) + + node_context = MockCloudifyContext(node_id='a20847', + properties=properties) + with mock.patch( + 'openstack_plugin_common._get_resource_by_name_or_id_from_ctx', + new=return_value): + return common.validate_resource(node_context, client_mock, os_type) + + def test_use_existing_resource(self): + self._test_validate_resource(True, True, True) + self._test_validate_resource(True, False, True) + + def test_create_resource(self): + client_mock = mock.MagicMock() + client_mock.cosmo_list.return_value = ['a', 'b', 'c'] + client_mock.get_quota.return_value = 5 + self._test_validate_resource(False, True, False, client_mock) + self._test_validate_resource(False, False, False, client_mock) + self._test_validate_resource(True, True, False, client_mock) + + def test_raise_error(self): + # If exists and shouldn't it is checked in resource + # validation so below scenario is not tested here + self.assertRaises(NonRecoverableError, + self._test_validate_resource, + is_external=True, + create_if_missing=False, + exists=False) + + def test_raise_quota_error(self): + client_mock = mock.MagicMock() + client_mock.cosmo_list.return_value = ['a', 'b', 'c'] + client_mock.get_quota.return_value = 3 + self.assertRaises(NonRecoverableError, + self._test_validate_resource, + is_external=True, + create_if_missing=True, + exists=False, + client_mock_provided=client_mock) diff --git a/aria/multivim-plugin/openstack_plugin_common/tests/provider-context.json b/aria/multivim-plugin/openstack_plugin_common/tests/provider-context.json new file mode 100644 index 0000000000..f7e20e4ef5 --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/tests/provider-context.json @@ -0,0 +1,78 @@ +{ + "context": { + "resources": { + "management_keypair": { + "name": "p2_cloudify-manager-kp-ilya", + "id": "p2_cloudify-manager-kp-ilya", + "type": "keypair", + "external_resource": true + }, + "router": { + "name": "p2_cloudify-router", + "id": "856f9fb8-6676-4b99-b64d-b76874b30abf", + "type": "router", + "external_resource": true + }, + "subnet": { + "name": "p2_cloudify-admin-network-subnet", + "id": "dd193491-d728-4e3e-8199-27eec0ba18e4", + "type": "subnet", + "external_resource": true + }, + "int_network": { + "name": "p2_cloudify-admin-network", + "id": "27ef2770-5219-4bb1-81d4-14ed450c5181", + "type": "network", + "external_resource": true + }, + "management_server": { + "name": "p2_cfy-mgr-ilya-2014-06-01-11:59", + "id": "be9991da-9c34-4f7c-9c33-5e04ad2d5b3e", + "type": "server", + "external_resource": false + }, + "agents_security_group": { + "name": "p2_cloudify-sg-agents", + "id": "d52280aa-0e79-4697-bd08-baf3f84e2a10", + "type": "neutron security group", + "external_resource": true + }, + "agents_keypair": { + "name": "p2_cloudify-agents-kp-ilya", + "id": "p2_cloudify-agents-kp-ilya", + "type": "keypair", + "external_resource": true + }, + "management_security_group": { + "name": "p2_cloudify-sg-management", + "id": "5862e0d2-8f28-472e-936b-d2da9cb935b3", + "type": "neutron security group", + "external_resource": true + }, + "floating_ip": { + "external_resource": true, + "id": "None", + "type": "floating ip", + "ip": "CENSORED" + }, + "ext_network": { + "name": "Ext-Net", + "id": "7da74520-9d5e-427b-a508-213c84e69616", + "type": "network", + "external_resource": true + } + }, + "cloudify": { + "resources_prefix": "p2_", + "cloudify_agent": { + "user": "ubuntu", + "agent_key_path": "/PATH/CENSORED/p2_cloudify-agents-kp-ilya.pem", + "min_workers": 2, + "max_workers": 5, + "remote_execution_port": 22 + } + } + }, + "name": "cloudify_openstack" +} + diff --git a/aria/multivim-plugin/openstack_plugin_common/tests/test.py b/aria/multivim-plugin/openstack_plugin_common/tests/test.py new file mode 100644 index 0000000000..13099292ca --- /dev/null +++ b/aria/multivim-plugin/openstack_plugin_common/tests/test.py @@ -0,0 +1,40 @@ +import json +import os + +from cloudify.context import BootstrapContext + +from cloudify.mocks import MockCloudifyContext + + +RETRY_AFTER = 1 +# Time during which no retry could possibly happen. +NO_POSSIBLE_RETRY_TIME = RETRY_AFTER / 2.0 + +BOOTSTRAP_CONTEXTS_WITHOUT_PREFIX = ( + { + }, + { + 'resources_prefix': '' + }, + { + 'resources_prefix': None + }, +) + + +def set_mock_provider_context(ctx, provider_context): + + def mock_provider_context(provider_name_unused): + return provider_context + + ctx.get_provider_context = mock_provider_context + + +def create_mock_ctx_with_provider_info(*args, **kw): + cur_dir = os.path.dirname(os.path.realpath(__file__)) + full_file_name = os.path.join(cur_dir, 'provider-context.json') + with open(full_file_name) as f: + provider_context = json.loads(f.read())['context'] + kw['provider_context'] = provider_context + kw['bootstrap_context'] = BootstrapContext(provider_context['cloudify']) + return MockCloudifyContext(*args, **kw) |