# 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. import functools import os import re from glanceclient import exc as glance_exc from novaclient import exceptions as nova_exc import six from rally.common.i18n import _ from rally.common import objects from rally import consts from rally import exceptions from rally import osclients from rally.plugins.openstack.context.nova import flavors as flavors_ctx from rally.plugins.openstack import types as openstack_types from rally.task import types from rally.verification.tempest import tempest # TODO(boris-42): make the validators usable as a functions as well. # At the moment validators can only be used as decorators. class ValidationResult(object): def __init__(self, is_valid, msg=None): self.is_valid = is_valid self.msg = msg def validator(fn): """Decorator that constructs a scenario validator from given function. Decorated function should return ValidationResult on error. :param fn: function that performs validation :returns: rally scenario validator """ def wrap_given(*args, **kwargs): """Dynamic validation decorator for scenario. :param args: the arguments of the decorator of the benchmark scenario ex. @my_decorator("arg1"), then args = ("arg1",) :param kwargs: the keyword arguments of the decorator of the scenario ex. @my_decorator(kwarg1="kwarg1"), then kwargs = {"kwarg1": "kwarg1"} """ @functools.wraps(fn) def wrap_validator(config, clients, deployment): # NOTE(amaretskiy): validator is successful by default return (fn(config, clients, deployment, *args, **kwargs) or ValidationResult(True)) def wrap_scenario(scenario): # TODO(boris-42): remove this in future. wrap_validator.permission = getattr(fn, "permission", consts.EndpointPermission.USER) scenario._meta_setdefault("validators", []) scenario._meta_get("validators").append(wrap_validator) return scenario return wrap_scenario return wrap_given @validator def number(config, clients, deployment, param_name, minval=None, maxval=None, nullable=False, integer_only=False): """Checks that parameter is number that pass specified condition. Ensure a parameter is within the range [minval, maxval]. This is a closed interval so the end points are included. :param param_name: Name of parameter to validate :param minval: Lower endpoint of valid interval :param maxval: Upper endpoint of valid interval :param nullable: Allow parameter not specified, or parameter=None :param integer_only: Only accept integers """ val = config.get("args", {}).get(param_name) num_func = float if integer_only: # NOTE(boris-42): Force check that passed value is not float, this is # important cause int(float_numb) won't raise exception if type(val) == float: return ValidationResult(False, "%(name)s is %(val)s which hasn't int type" % {"name": param_name, "val": val}) num_func = int # None may be valid if the scenario sets a sensible default. if nullable and val is None: return ValidationResult(True) try: number = num_func(val) if minval is not None and number < minval: return ValidationResult( False, "%(name)s is %(val)s which is less than the minimum " "(%(min)s)" % {"name": param_name, "val": number, "min": minval}) if maxval is not None and number > maxval: return ValidationResult( False, "%(name)s is %(val)s which is greater than the maximum " "(%(max)s)" % {"name": param_name, "val": number, "max": maxval}) return ValidationResult(True) except (ValueError, TypeError): return ValidationResult( False, "%(name)s is %(val)s which is not a valid %(type)s" % {"name": param_name, "val": val, "type": num_func.__name__}) def _file_access_ok(filename, mode, param_name, required=True): if not filename: return ValidationResult(not required, "Parameter %s required" % param_name) if not os.access(os.path.expanduser(filename), mode): return ValidationResult( False, "Could not open %(filename)s with mode %(mode)s " "for parameter %(param_name)s" % {"filename": filename, "mode": mode, "param_name": param_name}) return ValidationResult(True) @validator def file_exists(config, clients, deployment, param_name, mode=os.R_OK, required=True): """Validator checks parameter is proper path to file with proper mode. Ensure a file exists and can be accessed with the specified mode. Note that path to file will be expanded before access checking. :param param_name: Name of parameter to validate :param mode: Access mode to test for. This should be one of: * os.F_OK (file exists) * os.R_OK (file is readable) * os.W_OK (file is writable) * os.X_OK (file is executable) If multiple modes are required they can be added, eg: mode=os.R_OK+os.W_OK :param required: Boolean indicating whether this argument is required. """ return _file_access_ok(config.get("args", {}).get(param_name), mode, param_name, required) def check_command_dict(command): """Check command-specifying dict `command', raise ValueError on error.""" # NOTE(pboldin): Here we check for the values not for presence of the keys # due to template-driven configuration generation that can leave keys # defined but values empty. if command.get("interpreter"): script_file = command.get("script_file") if script_file: command["script_file"] = os.path.expanduser(script_file) if "script_inline" in command: raise ValueError( "Exactly one of script_inline or script_file with " "interpreter is expected: %r" % command) # User tries to upload a shell? Make sure it is same as interpreter interpreter = command.get("interpreter") interpreter = (interpreter[-1] if isinstance(interpreter, (tuple, list)) else interpreter) if (command.get("local_path") and command.get("remote_path") != interpreter): raise ValueError( "When uploading an interpreter its path should be as well" " specified as the `remote_path' string: %r" % command) elif not command.get("remote_path"): # No interpreter and no remote command to execute is given raise ValueError( "Supplied dict specifies no command to execute," " either interpreter or remote_path is required: %r" % command) @validator def valid_command(config, clients, deployment, param_name, required=True): """Checks that parameter is a proper command-specifying dictionary. Ensure that the command dictionary is a proper command-specifying dictionary described in `vmtasks.VMTasks.boot_runcommand_delete' docstring. :param param_name: Name of parameter to validate :param required: Boolean indicating that the command dictionary is required """ # TODO(pboldin): Make that a `jsonschema' check once generic validator # is available. command = config.get("args", {}).get(param_name) if command is None: return ValidationResult(not required, "Command dicitionary is required") try: check_command_dict(command) except ValueError as e: return ValidationResult(False, str(e)) for key in "script_file", "local_path": if command.get(key): return _file_access_ok( filename=command[key], mode=os.R_OK, param_name=param_name + "." + key, required=True) return ValidationResult(True) def _get_validated_image(config, clients, param_name): image_context = config.get("context", {}).get("images", {}) image_args = config.get("args", {}).get(param_name) image_ctx_name = image_context.get("image_name") if not image_args: msg = _("Parameter %s is not specified.") % param_name return (ValidationResult(False, msg), None) if "image_name" in image_context: # NOTE(rvasilets) check string is "exactly equal to" a regex # or image name from context equal to image name from args if "regex" in image_args: match = re.match(image_args.get("regex"), image_ctx_name) if image_ctx_name == image_args.get("name") or ( "regex" in image_args and match): image = { "size": image_context.get("min_disk", 0), "min_ram": image_context.get("min_ram", 0), "min_disk": image_context.get("min_disk", 0) } return (ValidationResult(True), image) try: image_id = openstack_types.GlanceImage.transform( clients=clients, resource_config=image_args) image = clients.glance().images.get(image=image_id).to_dict() if not image.get("size"): image["size"] = 0 if not image.get("min_ram"): image["min_ram"] = 0 if not image.get("min_disk"): image["min_disk"] = 0 return (ValidationResult(True), image) except (glance_exc.HTTPNotFound, exceptions.InvalidScenarioArgument): message = _("Image '%s' not found") % image_args return (ValidationResult(False, message), None) def _get_flavor_from_context(config, flavor_value): if "flavors" not in config.get("context", {}): raise exceptions.InvalidScenarioArgument("No flavors context") flavors = [flavors_ctx.FlavorConfig(**f) for f in config["context"]["flavors"]] resource = types.obj_from_name(resource_config=flavor_value, resources=flavors, typename="flavor") flavor = flavors_ctx.FlavorConfig(**resource) flavor.id = "" % flavor.name return (ValidationResult(True), flavor) def _get_validated_flavor(config, clients, param_name): flavor_value = config.get("args", {}).get(param_name) if not flavor_value: msg = "Parameter %s is not specified." % param_name return (ValidationResult(False, msg), None) try: flavor_id = openstack_types.Flavor.transform( clients=clients, resource_config=flavor_value) flavor = clients.nova().flavors.get(flavor=flavor_id) return (ValidationResult(True), flavor) except (nova_exc.NotFound, exceptions.InvalidScenarioArgument): try: return _get_flavor_from_context(config, flavor_value) except exceptions.InvalidScenarioArgument: pass message = _("Flavor '%s' not found") % flavor_value return (ValidationResult(False, message), None) @validator def validate_share_proto(config, clients, deployment): """Validates value of share protocol for creation of Manila share.""" allowed = ("NFS", "CIFS", "GLUSTERFS", "HDFS", ) share_proto = config.get("args", {}).get("share_proto") if six.text_type(share_proto).upper() not in allowed: message = _("Share protocol '%(sp)s' is invalid, allowed values are " "%(allowed)s.") % {"sp": share_proto, "allowed": "', '".join(allowed)} return ValidationResult(False, message) @validator def image_exists(config, clients, deployment, param_name, nullable=False): """Returns validator for image_id :param param_name: defines which variable should be used to get image id value. :param nullable: defines image id param is required """ image_value = config.get("args", {}).get(param_name) if not image_value and nullable: return ValidationResult(True) return _get_validated_image(config, clients, param_name)[0] @validator def flavor_exists(config, clients, deployment, param_name): """Returns validator for flavor :param param_name: defines which variable should be used to get flavor id value. """ return _get_validated_flavor(config, clients, param_name)[0] @validator def image_valid_on_flavor(config, clients, deployment, flavor_name, image_name, validate_disk=True): """Returns validator for image could be used for current flavor :param flavor_name: defines which variable should be used to get flavor id value. :param image_name: defines which variable should be used to get image id value. :param validate_disk: flag to indicate whether to validate flavor's disk. Should be True if instance is booted from image. Should be False if instance is booted from volume. Default value is True. """ valid_result, flavor = _get_validated_flavor(config, clients, flavor_name) if not valid_result.is_valid: return valid_result valid_result, image = _get_validated_image(config, clients, image_name) if not valid_result.is_valid: return valid_result if flavor.ram < image["min_ram"]: message = _("The memory size for flavor '%s' is too small " "for requested image '%s'") % (flavor.id, image["id"]) return ValidationResult(False, message) if flavor.disk and validate_disk: if image["size"] > flavor.disk * (1024 ** 3): message = _("The disk size for flavor '%s' is too small " "for requested image '%s'") % (flavor.id, image["id"]) return ValidationResult(False, message) if image["min_disk"] > flavor.disk: message = _("The disk size for flavor '%s' is too small " "for requested image '%s'") % (flavor.id, image["id"]) return ValidationResult(False, message) @validator def network_exists(config, clients, deployment, network_name): """Validator checks that network with network_name exist.""" network = config.get("args", {}).get(network_name, "private") networks = [net.label for net in clients.nova().networks.list()] if network not in networks: message = _("Network with name %(network)s not found. " "Available networks: %(networks)s") % { "network": network, "networks": networks } return ValidationResult(False, message) @validator def external_network_exists(config, clients, deployment, network_name): """Validator checks that external network with given name exists.""" ext_network = config.get("args", {}).get(network_name) if not ext_network: return ValidationResult(True) networks = [net.name for net in clients.nova().floating_ip_pools.list()] if networks and isinstance(networks[0], dict): networks = [n["name"] for n in networks] if ext_network not in networks: message = _("External (floating) network with name %(network)s " "not found. " "Available networks: %(networks)s") % { "network": ext_network, "networks": networks} return ValidationResult(False, message) @validator def tempest_tests_exists(config, clients, deployment): """Validator checks that specified test exists.""" args = config.get("args", {}) if "test_name" in args: tests = [args["test_name"]] else: tests = args.get("test_names", []) if not tests: return ValidationResult(False, _("Parameter 'test_name' or 'test_names' " "should be specified.")) verifier = tempest.Tempest( deployment["uuid"], source=config.get("context", {}).get("tempest", {}).get("source")) if not verifier.is_installed(): try: verifier.install() except tempest.TempestSetupFailure as e: return ValidationResult(False, e) if not verifier.is_configured(): verifier.generate_config_file() allowed_tests = verifier.discover_tests() for i, test in enumerate(tests): if not test.startswith("tempest.api."): tests[i] = "tempest.api." + test wrong_tests = set(tests) - allowed_tests if wrong_tests: message = (_("One or more tests not found: '%s'") % "', '".join(sorted(wrong_tests))) return ValidationResult(False, message) @validator def tempest_set_exists(config, clients, deployment): """Validator that check that tempest set_name is valid.""" set_name = config.get("args", {}).get("set_name") if not set_name: return ValidationResult(False, "`set_name` is not specified.") if set_name not in (list(consts.TempestTestsSets) + list(consts.TempestTestsAPI)): message = _("There is no tempest set with name '%s'.") % set_name return ValidationResult(False, message) @validator def required_parameters(config, clients, deployment, *required_params): """Validator for checking required parameters are specified. :param *required_params: list of required parameters """ missing = set(required_params) - set(config.get("args", {})) if missing: message = _("%s parameters are not defined in " "the benchmark config file") % ", ".join(missing) return ValidationResult(False, message) @validator def required_services(config, clients, deployment, *required_services): """Validator checks if specified OpenStack services are available. :param *required_services: list of services names """ available_services = list(clients.services().values()) if consts.Service.NOVA_NET in required_services: nova = osclients.Clients( objects.Credential(**deployment["admin"])).nova() for service in nova.services.list(): if (service.binary == consts.Service.NOVA_NET and service.status == "enabled"): available_services.append(consts.Service.NOVA_NET) for service in required_services: # NOTE(andreykurilin): validator should ignore services configured via # context(a proper validation should be in context) service_config = config.get("context", {}).get( "api_versions", {}).get(service, {}) if (service not in available_services and not ("service_type" in service_config or "service_name" in service_config)): return ValidationResult( False, _("'{0}' service is not available. Hint: If '{0}' " "service has non-default service_type, try to setup " "it via 'api_versions' context.").format(service)) @validator def required_neutron_extensions(config, clients, deployment, *required_extensions): """Validator checks if the specified Neutron extension is available :param required_extensions: list of Neutron extensions """ extensions = clients.neutron().list_extensions().get("extensions", []) aliases = map(lambda x: x["alias"], extensions) for extension in required_extensions: if extension not in aliases: msg = (_("Neutron extension %s is not configured") % extension) return ValidationResult(False, msg) @validator def required_cinder_services(config, clients, deployment, service_name): """Validator checks that specified Cinder service is available. It uses Cinder client with admin permissions to call 'cinder service-list' call :param service_name: Cinder service name """ admin_client = osclients.Clients( objects.Credential(**deployment["admin"])).cinder() for service in admin_client.services.list(): if (service.binary == six.text_type(service_name) and service.state == six.text_type("up")): return ValidationResult(True) msg = _("%s service is not available") % service_name return ValidationResult(False, msg) @validator def required_clients(config, clients, deployment, *components, **kwargs): """Validator checks if specified OpenStack clients are available. :param *components: list of client components names :param **kwargs: optional parameters: admin - bool, whether to use admin clients """ if kwargs.get("admin", False): clients = osclients.Clients(objects.Credential(**deployment["admin"])) for client_component in components: try: getattr(clients, client_component)() except ImportError: return ValidationResult( False, _("Client for %s is not installed. To install it run " "`pip install -r" " optional-requirements.txt`") % client_component) @validator def required_contexts(config, clients, deployment, *context_names): """Validator checks if required benchmark contexts are specified. :param *context_names: list of context names that should be specified """ missing_contexts = set(context_names) - set(config.get("context", {})) if missing_contexts: message = (_("The following contexts are required but missing from " "the benchmark configuration file: %s") % ", ".join(missing_contexts)) return ValidationResult(False, message) @validator def required_openstack(config, clients, deployment, admin=False, users=False): """Validator that requires OpenStack admin or (and) users. This allows us to create 4 kind of benchmarks: 1) not OpenStack related (validator is not specified) 2) requires OpenStack admin 3) requires OpenStack admin + users 4) requires OpenStack users :param admin: requires OpenStack admin :param users: requires OpenStack users """ if not (admin or users): return ValidationResult( False, _("You should specify admin=True or users=True or both.")) if deployment["admin"] and deployment["users"]: return ValidationResult(True) if deployment["admin"]: if users and not config.get("context", {}).get("users"): return ValidationResult(False, _("You should specify 'users' context")) return ValidationResult(True) if deployment["users"] and admin: return ValidationResult(False, _("Admin credentials required")) @validator def required_api_versions(config, clients, deployment, component, versions): """Validator checks component API versions.""" versions = [str(v) for v in versions] versions_str = ", ".join(versions) msg = _("Task was designed to be used with %(component)s " "V%(version)s, but V%(found_version)s is " "selected.") if component == "keystone": if "2.0" not in versions and hasattr(clients.keystone(), "tenants"): return ValidationResult(False, msg % {"component": component, "version": versions_str, "found_version": "2.0"}) if "3" not in versions and hasattr(clients.keystone(), "projects"): return ValidationResult(False, msg % {"component": component, "version": versions_str, "found_version": "3"}) else: used_version = config.get("context", {}).get("api_versions", {}).get( component, {}).get("version", getattr(clients, component).choose_version()) if not used_version: return ValidationResult( False, _("Unable to determine the API version.")) if str(used_version) not in versions: return ValidationResult( False, msg % {"component": component, "version": versions_str, "found_version": used_version}) @validator def volume_type_exists(config, clients, deployment, param_name): """Returns validator for volume types. check_types: defines variable to be used as the flag to determine if volume types should be checked for existence. """ val = config.get("args", {}).get(param_name) if val: volume_types_list = clients.cinder().volume_types.list() if not volume_types_list: message = (_("Must have at least one volume type created " "when specifying use of volume types.")) return ValidationResult(False, message) @validator def restricted_parameters(config, clients, deployment, param_names, subdict=None): """Validates that parameters is not set. :param param_names: parameter or parameters list to be validated. :param subdict: sub-dict of "config" to search for param_names. if not defined - will search in "config" """ if not isinstance(param_names, (list, tuple)): param_names = [param_names] restricted_params = [] for param_name in param_names: args = config.get("args", {}) a_dict, a_key = (args, subdict) if subdict else (config, "args") if param_name in a_dict.get(a_key, {}): restricted_params.append(param_name) if restricted_params: msg = (_("You can't specify parameters '%(params)s' in '%(a_dict)s'") % {"params": ", ".join(restricted_params), "a_dict": subdict if subdict else "args"}) return ValidationResult(False, msg) @validator def validate_heat_template(config, clients, deployment, *param_names): """Validates heat template. :param param_names: list of parameters to be validated. """ if param_names is None: return ValidationResult(False, _( "validate_heat_template validator accepts non empty arguments " "in form of `validate_heat_template(\"foo\", \"bar\")`")) for param_name in param_names: template_path = config.get("args", {}).get(param_name) if not template_path: return ValidationResult(False, _( "Path to heat template is not specified. Its needed for " "heat template validation. Please check the content of `%s` " "scenario argument.") % param_name) template_path = os.path.expanduser(template_path) if not os.path.exists(template_path): return ValidationResult(False, _("No file found by the given path " "%s") % template_path) with open(template_path, "r") as f: try: clients.heat().stacks.validate(template=f.read()) except Exception as e: dct = { "path": template_path, "msg": str(e), } msg = (_("Heat template validation failed on %(path)s. " "Original error message: %(msg)s.") % dct) return ValidationResult(False, msg)