# Copyright 2014: 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 distutils import version import inspect import os import re from neutronclient import version as nc_version from oslo_config import cfg import requests import six from six.moves import configparser from six.moves.urllib import parse from rally.common import db from rally.common.i18n import _ from rally.common import logging from rally.common import objects from rally.common import utils from rally import exceptions from rally import osclients from rally.plugins.openstack.wrappers import glance as glance_wrapper from rally.plugins.openstack.wrappers import network LOG = logging.getLogger(__name__) IMAGE_OPTS = [ cfg.StrOpt("cirros_img_url", default="http://download.cirros-cloud.net/" "0.3.4/cirros-0.3.4-x86_64-disk.img", help="CirrOS image URL"), cfg.StrOpt("disk_format", default="qcow2", help="Image disk format to use when creating the image"), cfg.StrOpt("container_format", default="bare", help="Image container format to use when creating the image"), cfg.StrOpt("name_regex", default="^.*(cirros|testvm).*$", help="Regular expression for name of an image to discover it " "in the cloud and use it for the tests. Note that when " "Rally is searching for the image, case insensitive " "matching is performed. Specify nothing ('name_regex =') " "if you want to disable discovering. In this case Rally " "will create needed resources by itself if the values " "for the corresponding config options are not specified " "in the Tempest config file") ] ROLE_OPTS = [ cfg.StrOpt("swift_operator_role", default="Member", help="Role required for users " "to be able to create Swift containers"), cfg.StrOpt("swift_reseller_admin_role", default="ResellerAdmin", help="User role that has reseller admin"), cfg.StrOpt("heat_stack_owner_role", default="heat_stack_owner", help="Role required for users " "to be able to manage Heat stacks"), cfg.StrOpt("heat_stack_user_role", default="heat_stack_user", help="Role for Heat template-defined users") ] CONF = cfg.CONF CONF.register_opts(IMAGE_OPTS, "image") CONF.register_opts(ROLE_OPTS, "role") def _create_or_get_data_dir(): data_dir = os.path.join( os.path.expanduser("~"), ".rally", "tempest", "data") if not os.path.exists(data_dir): os.makedirs(data_dir) return data_dir def _write_config(conf_path, conf_data): with open(conf_path, "w+") as conf_file: conf_data.write(conf_file) class TempestConfig(utils.RandomNameGeneratorMixin): """Class to generate Tempest configuration file.""" def __init__(self, deployment): self.deployment = deployment self.credential = db.deployment_get(deployment)["admin"] self.clients = osclients.Clients(objects.Credential(**self.credential)) self.keystone = self.clients.verified_keystone() self.available_services = self.clients.services().values() self.data_dir = _create_or_get_data_dir() self.conf = configparser.ConfigParser() self.conf.read(os.path.join(os.path.dirname(__file__), "config.ini")) self.image_name = parse.urlparse( CONF.image.cirros_img_url).path.split("/")[-1] self._download_cirros_image() def _download_cirros_image(self): img_path = os.path.join(self.data_dir, self.image_name) if os.path.isfile(img_path): return try: response = requests.get(CONF.image.cirros_img_url, stream=True) except requests.ConnectionError as err: msg = _("Failed to download CirrOS image. " "Possibly there is no connection to Internet. " "Error: %s.") % (str(err) or "unknown") raise exceptions.TempestConfigCreationFailure(msg) if response.status_code == 200: with open(img_path + ".tmp", "wb") as img_file: for chunk in response.iter_content(chunk_size=1024): if chunk: # filter out keep-alive new chunks img_file.write(chunk) img_file.flush() os.rename(img_path + ".tmp", img_path) else: if response.status_code == 404: msg = _("Failed to download CirrOS image. " "Image was not found.") else: msg = _("Failed to download CirrOS image. " "HTTP error code %d.") % response.status_code raise exceptions.TempestConfigCreationFailure(msg) def _get_service_url(self, service_name): s_type = self._get_service_type_by_service_name(service_name) available_endpoints = self.keystone.service_catalog.get_endpoints() service_endpoints = available_endpoints.get(s_type, []) for endpoint in service_endpoints: # If endpoints were returned by Keystone API V2 if "publicURL" in endpoint: return endpoint["publicURL"] # If endpoints were returned by Keystone API V3 if endpoint["interface"] == "public": return endpoint["url"] def _get_service_type_by_service_name(self, service_name): for s_type, s_name in six.iteritems(self.clients.services()): if s_name == service_name: return s_type def _configure_boto(self, section_name="boto"): self.conf.set(section_name, "ec2_url", self._get_service_url("ec2")) self.conf.set(section_name, "s3_url", self._get_service_url("s3")) self.conf.set(section_name, "s3_materials_path", os.path.join(self.data_dir, "s3materials")) # TODO(olkonami): find out how can we get ami, ari, aki manifest files def _configure_default(self, section_name="DEFAULT"): # Nothing to configure in this section for now pass def _configure_dashboard(self, section_name="dashboard"): url = "http://%s/" % parse.urlparse( self.credential["auth_url"]).hostname self.conf.set(section_name, "dashboard_url", url) # Sahara has two service types: 'data_processing' and 'data-processing'. # 'data_processing' is deprecated, but it can be used in previous OpenStack # releases. So we need to configure the 'catalog_type' option to support # environments where 'data_processing' is used as service type for Sahara. def _configure_data_processing(self, section_name="data-processing"): if "sahara" in self.available_services: self.conf.set(section_name, "catalog_type", self._get_service_type_by_service_name("sahara")) def _configure_identity(self, section_name="identity"): self.conf.set(section_name, "username", self.credential["username"]) self.conf.set(section_name, "password", self.credential["password"]) self.conf.set(section_name, "tenant_name", self.credential["tenant_name"]) self.conf.set(section_name, "admin_username", self.credential["username"]) self.conf.set(section_name, "admin_password", self.credential["password"]) self.conf.set(section_name, "admin_tenant_name", self.credential["tenant_name"]) self.conf.set(section_name, "region", self.credential["region_name"]) url_trailer = parse.urlparse(self.credential["auth_url"]).path self.conf.set(section_name, "auth_version", url_trailer[1:3]) self.conf.set(section_name, "uri", self.credential["auth_url"]) self.conf.set(section_name, "uri_v3", self.credential["auth_url"].replace(url_trailer, "/v3")) self.conf.set(section_name, "admin_domain_name", self.credential["admin_domain_name"]) self.conf.set(section_name, "disable_ssl_certificate_validation", str(self.credential["https_insecure"])) self.conf.set(section_name, "ca_certificates_file", self.credential["https_cacert"]) # The compute section is configured in context class for Tempest resources. # Options which are configured there: 'image_ref', 'image_ref_alt', # 'flavor_ref', 'flavor_ref_alt'. def _configure_network(self, section_name="network"): if "neutron" in self.available_services: neutronclient = self.clients.neutron() public_nets = [net for net in neutronclient.list_networks()["networks"] if net["status"] == "ACTIVE" and net["router:external"] is True] if public_nets: net_id = public_nets[0]["id"] self.conf.set(section_name, "public_network_id", net_id) else: novaclient = self.clients.nova() net_name = next(net.human_id for net in novaclient.networks.list() if net.human_id is not None) self.conf.set("compute", "fixed_network_name", net_name) self.conf.set("compute", "network_for_ssh", net_name) def _configure_network_feature_enabled( self, section_name="network-feature-enabled"): if "neutron" in self.available_services: neutronclient = self.clients.neutron() # NOTE(ylobankov): We need the if/else block here because # the list_ext method has different number of arguments in # different Neutron client versions. cl_ver = nc_version.__version__ if version.StrictVersion(cl_ver) >= version.StrictVersion("4.1.0"): # Neutron client version >= 4.1.0 extensions = neutronclient.list_ext( "extensions", "/extensions", retrieve_all=True) else: # Neutron client version < 4.1.0 extensions = neutronclient.list_ext("/extensions") aliases = [ext["alias"] for ext in extensions["extensions"]] aliases_str = ",".join(aliases) self.conf.set(section_name, "api_extensions", aliases_str) def _configure_oslo_concurrency(self, section_name="oslo_concurrency"): lock_path = os.path.join(self.data_dir, "lock_files_%s" % self.deployment) if not os.path.exists(lock_path): os.makedirs(lock_path) self.conf.set(section_name, "lock_path", lock_path) def _configure_object_storage(self, section_name="object-storage"): self.conf.set(section_name, "operator_role", CONF.role.swift_operator_role) self.conf.set(section_name, "reseller_admin_role", CONF.role.swift_reseller_admin_role) def _configure_scenario(self, section_name="scenario"): self.conf.set(section_name, "img_dir", self.data_dir) self.conf.set(section_name, "img_file", self.image_name) def _configure_service_available(self, section_name="service_available"): services = ["ceilometer", "cinder", "glance", "heat", "ironic", "neutron", "nova", "sahara", "swift"] for service in services: # Convert boolean to string because ConfigParser fails # on attempt to get option with boolean value self.conf.set(section_name, service, str(service in self.available_services)) def _configure_horizon_available(self, section_name="service_available"): horizon_url = ("http://" + parse.urlparse(self.credential["auth_url"]).hostname) try: horizon_req = requests.get( horizon_url, timeout=CONF.openstack_client_http_timeout) except requests.RequestException as e: LOG.debug("Failed to connect to Horizon: %s" % e) horizon_availability = False else: horizon_availability = (horizon_req.status_code == 200) # Convert boolean to string because ConfigParser fails # on attempt to get option with boolean value self.conf.set(section_name, "horizon", str(horizon_availability)) def _configure_validation(self, section_name="validation"): if "neutron" in self.available_services: self.conf.set(section_name, "connect_method", "floating") else: self.conf.set(section_name, "connect_method", "fixed") def _configure_orchestration(self, section_name="orchestration"): self.conf.set(section_name, "stack_owner_role", CONF.role.heat_stack_owner_role) self.conf.set(section_name, "stack_user_role", CONF.role.heat_stack_user_role) def generate(self, conf_path=None): for name, method in inspect.getmembers(self, inspect.ismethod): if name.startswith("_configure_"): method() if conf_path: _write_config(conf_path, self.conf) class TempestResourcesContext(utils.RandomNameGeneratorMixin): """Context class to create/delete resources needed for Tempest.""" RESOURCE_NAME_FORMAT = "rally_verify_XXXXXXXX_XXXXXXXX" def __init__(self, deployment, verification, conf_path): credential = db.deployment_get(deployment)["admin"] self.clients = osclients.Clients(objects.Credential(**credential)) self.available_services = self.clients.services().values() self.verification = verification self.conf_path = conf_path self.conf = configparser.ConfigParser() self.conf.read(conf_path) self.image_name = parse.urlparse( CONF.image.cirros_img_url).path.split("/")[-1] self._created_roles = [] self._created_images = [] self._created_flavors = [] self._created_networks = [] def __enter__(self): self._create_tempest_roles() self._configure_option("compute", "image_ref", self._discover_or_create_image) self._configure_option("compute", "image_ref_alt", self._discover_or_create_image) self._configure_option("compute", "flavor_ref", self._discover_or_create_flavor, 64) self._configure_option("compute", "flavor_ref_alt", self._discover_or_create_flavor, 128) if "neutron" in self.available_services: neutronclient = self.clients.neutron() if neutronclient.list_networks(shared=True)["networks"]: # If the OpenStack cloud has some shared networks, we will # create our own shared network and specify its name in the # Tempest config file. Such approach will allow us to avoid # failures of Tempest tests with error "Multiple possible # networks found". Otherwise the default behavior defined in # Tempest will be used and Tempest itself will manage network # resources. LOG.debug("Shared networks found. " "'fixed_network_name' option should be configured") self._configure_option("compute", "fixed_network_name", self._create_network_resources) if "heat" in self.available_services: self._configure_option("orchestration", "instance_type", self._discover_or_create_flavor, 64) _write_config(self.conf_path, self.conf) def __exit__(self, exc_type, exc_value, exc_traceback): # Tempest tests may take more than 1 hour and we should remove all # cached clients sessions to avoid tokens expiration when deleting # Tempest resources. self.clients.clear() self._cleanup_tempest_roles() self._cleanup_images() self._cleanup_flavors() if "neutron" in self.available_services: self._cleanup_network_resources() _write_config(self.conf_path, self.conf) def _create_tempest_roles(self): keystoneclient = self.clients.verified_keystone() roles = [CONF.role.swift_operator_role, CONF.role.swift_reseller_admin_role, CONF.role.heat_stack_owner_role, CONF.role.heat_stack_user_role] existing_roles = set(role.name for role in keystoneclient.roles.list()) for role in roles: if role not in existing_roles: LOG.debug("Creating role '%s'" % role) self._created_roles.append(keystoneclient.roles.create(role)) def _configure_option(self, section, option, create_method, *args, **kwargs): option_value = self.conf.get(section, option) if not option_value: LOG.debug("Option '%s' from '%s' section " "is not configured" % (option, section)) resource = create_method(*args, **kwargs) value = resource["name"] if "network" in option else resource.id LOG.debug("Setting value '%s' for option '%s'" % (value, option)) self.conf.set(section, option, value) LOG.debug("Option '{opt}' is configured. " "{opt} = {value}".format(opt=option, value=value)) else: LOG.debug("Option '{opt}' is already configured " "in Tempest config file. {opt} = {opt_val}" .format(opt=option, opt_val=option_value)) def _discover_or_create_image(self): glance_wrap = glance_wrapper.wrap(self.clients.glance, self) if CONF.image.name_regex: LOG.debug("Trying to discover an image with name matching " "regular expression '%s'. Note that case insensitive " "matching is performed" % CONF.image.name_regex) img_list = [img for img in self.clients.glance().images.list() if img.status.lower() == "active" and img.name] for img in img_list: if re.match(CONF.image.name_regex, img.name, re.IGNORECASE): LOG.debug("The following image discovered: '{0}'. Using " "image '{0}' for the tests".format(img.name)) return img LOG.debug("There is no image with name matching " "regular expression '%s'" % CONF.image.name_regex) params = { "name": self.generate_random_name(), "disk_format": CONF.image.disk_format, "container_format": CONF.image.container_format, "image_location": os.path.join(_create_or_get_data_dir(), self.image_name), "is_public": True } LOG.debug("Creating image '%s'" % params["name"]) image = glance_wrap.create_image(**params) self._created_images.append(image) return image def _discover_or_create_flavor(self, flv_ram): novaclient = self.clients.nova() LOG.debug("Trying to discover a flavor with the following " "properties: RAM = %dMB, VCPUs = 1, disk = 0GB" % flv_ram) for flavor in novaclient.flavors.list(): if (flavor.ram == flv_ram and flavor.vcpus == 1 and flavor.disk == 0): LOG.debug("The following flavor discovered: '{0}'. Using " "flavor '{0}' for the tests".format(flavor.name)) return flavor LOG.debug("There is no flavor with the mentioned properties") params = { "name": self.generate_random_name(), "ram": flv_ram, "vcpus": 1, "disk": 0 } LOG.debug("Creating flavor '%s' with the following properties: RAM " "= %dMB, VCPUs = 1, disk = 0GB" % (params["name"], flv_ram)) flavor = novaclient.flavors.create(**params) self._created_flavors.append(flavor) return flavor def _create_network_resources(self): neutron_wrapper = network.NeutronWrapper(self.clients, self) tenant_name = self.clients.keystone().tenant_name tenant_id = self.clients.keystone().get_project_id(tenant_name) LOG.debug("Creating network resources: network, subnet, router") net = neutron_wrapper.create_network( tenant_id, subnets_num=1, add_router=True, network_create_args={"shared": True}) self._created_networks.append(net) return net def _cleanup_tempest_roles(self): keystoneclient = self.clients.keystone() for role in self._created_roles: LOG.debug("Deleting role '%s'" % role.name) keystoneclient.roles.delete(role.id) def _cleanup_images(self): glanceclient = self.clients.glance() for image in self._created_images: LOG.debug("Deleting image '%s'" % image.name) glanceclient.images.delete(image.id) self._remove_opt_value_from_config("compute", image.id) def _cleanup_flavors(self): novaclient = self.clients.nova() for flavor in self._created_flavors: LOG.debug("Deleting flavor '%s'" % flavor.name) novaclient.flavors.delete(flavor.id) self._remove_opt_value_from_config("compute", flavor.id) self._remove_opt_value_from_config("orchestration", flavor.id) def _cleanup_network_resources(self): neutron_wrapper = network.NeutronWrapper(self.clients, self) for net in self._created_networks: LOG.debug("Deleting network resources: router, subnet, network") neutron_wrapper.delete_network(net) self._remove_opt_value_from_config("compute", net["name"]) def _remove_opt_value_from_config(self, section, opt_value): for option, value in self.conf.items(section): if opt_value == value: LOG.debug("Removing value '%s' for option '%s' " "from Tempest config file" % (opt_value, option)) self.conf.set(section, option, "")