def validate_process_activity(activity: Activity): """ Validate a process activity. A process activity requires: * a `"path"` key which is an absolute path to an executable the current user can call In all failing cases, raises :exc:`InvalidActivity`. This should be considered as a private function. """ name = activity["name"] provider = activity["provider"] path = raw_path = provider.get("path") if not path: raise InvalidActivity("a process activity must have a path") path = shutil.which(path) if not path: raise InvalidActivity( "path '{path}' cannot be found, in activity '{name}'".format( path=raw_path, name=name)) if not os.access(path, os.X_OK): raise InvalidActivity( "no access permission to '{path}', in activity '{name}'".format( path=raw_path, name=name))
def check_json_path(tolerance: Tolerance): """ Check the JSON path of a tolerance and raise :exc:`InvalidActivity` when the path is missing or invalid. See: https://github.com/h2non/jsonpath-ng """ if not HAS_JSONPATH: raise InvalidActivity( "Install the `jsonpath_ng` package to use a JSON path tolerance: " "`pip install chaostoolkit-lib[jsonpath]`.") if "path" not in tolerance: raise InvalidActivity( "hypothesis jsonpath probe tolerance must have a `path` key") try: path = tolerance.get("path") jparse.parse(path) except TypeError as t: raise InvalidActivity( "hypothesis probe tolerance path {} has an invalid type".format( path)) except JsonPathLexerError as e: raise InvalidActivity( "hypothesis probe tolerance JSON path '{}' is invalid: {}".format( str(e)))
def ensure_hypothesis_tolerance_is_valid(tolerance: Tolerance): """ Validate the tolerance of the hypothesis probe and raises :exc:`InvalidActivity` if it isn't valid. """ if not isinstance(tolerance, (bool, int, list, str, dict)): raise InvalidActivity( "hypothesis probe tolerance must either be an integer, " "a string, a boolean or a pair of values for boundaries. " "It can also be a dictionary which is a probe activity " "definition that takes an argument called `value` with " "the value of the probe itself to be validated") if isinstance(tolerance, dict): tolerance_type = tolerance.get("type") if tolerance_type == "probe": ensure_activity_is_valid(tolerance) elif tolerance_type == "regex": check_regex_pattern(tolerance) elif tolerance_type == "jsonpath": check_json_path(tolerance) elif tolerance_type == "range": check_range(tolerance) else: raise InvalidActivity( "hypothesis probe tolerance type '{}' is unsupported".format( tolerance_type))
def validate_http_activity(activity: Activity): """ Validate a HTTP activity. A process activity requires: * a `"url"` key which is the address to call In addition, you can pass the followings: * `"method"` which is the HTTP verb to use (default to `"GET"`) * `"headers"` which must be a mapping of string to string In all failing cases, raises :exc:`InvalidActivity`. This should be considered as a private function. """ provider = activity["provider"] url = provider.get("url") if not url: raise InvalidActivity("a HTTP activity must have a URL") headers = provider.get("headers") if headers and not type(headers) == dict: raise InvalidActivity("a HTTP activities expect headers as a mapping")
def check_range(tolerance: Tolerance): """ Check a value is within a given range. That range may be set to a min and max value or a sequence. """ if "range" not in tolerance: raise InvalidActivity( "hypothesis range probe tolerance must have a `range` key") the_range = tolerance["range"] if not isinstance(the_range, list): raise InvalidActivity( "hypothesis range must be a sequence") if len(the_range) != 2: raise InvalidActivity( "hypothesis range sequence must be made of two values") if not isinstance(the_range[0], Number): raise InvalidActivity( "hypothesis range lower boundary must be a number") if not isinstance(the_range[1], Number): raise InvalidActivity( "hypothesis range upper boundary must be a number")
def check_json_path(tolerance: Tolerance): """ Check the JSON path of a tolerance and raise :exc:`InvalidActivity` when the path is missing or invalid. See: https://github.com/h2non/jsonpath-ng """ if not HAS_JSONPATH: raise InvalidActivity( "Install the `jsonpath2` package to use a JSON path tolerance: " "`pip install chaostoolkit-lib[jsonpath]`.") if "path" not in tolerance: raise InvalidActivity( "hypothesis jsonpath probe tolerance must have a `path` key") try: path = tolerance.get("path", "").strip() if not path: raise InvalidActivity( "hypothesis probe tolerance JSON path cannot be empty") JSONPath.parse_str(path) except ValueError: raise InvalidActivity( "hypothesis probe tolerance JSON path {} is invalid".format(path)) except TypeError: raise InvalidActivity( "hypothesis probe tolerance JSON path {} has an invalid " "type".format(path))
def ensure_hypothesis_is_valid(experiment: Experiment): """ Validates that the steady state hypothesis entry has the expected schema or raises :exc:`InvalidExperiment` or :exc:`InvalidProbe`. """ hypo = experiment.get("steady-state-hypothesis") if hypo is None: return if not hypo.get("title"): raise InvalidExperiment("hypothesis requires a title") probes = hypo.get("probes") if probes: for probe in probes: ensure_activity_is_valid(probe) if "tolerance" not in probe: raise InvalidActivity( "hypothesis probe must have a tolerance entry") if not isinstance(probe["tolerance"], ( bool, int, list, str, dict)): raise InvalidActivity( "hypothesis probe tolerance must either be an integer, " "a string, a boolean or a pair of values for boundaries. " "It can also be a dictionary which is a probe activity " "definition that takes an argument called `value` with " "the value of the probe itself to be validated") if isinstance(probe, dict): ensure_activity_is_valid(probe)
def validate_python_control(control: Control): """ Verify that a control block matches the specification """ name = control["name"] provider = control["provider"] mod_name = provider.get("module") if not mod_name: raise InvalidActivity(f"Control '{name}' must have a module path") try: importlib.import_module(mod_name) except ImportError: logger.warning( "Could not find Python module '{mod}' " "in control '{name}'. Did you install the Python " "module? The experiment will carry on running " "nonetheless.".format(mod=mod_name, name=name) ) # a control can validate itself too # ideally, do it cleanly and raise chaoslib.exceptions.InvalidActivity func = load_func(control, "validate_control") if not func: return func(control)
def validate_python_control(control: Control): """ Verify that a control block matches the specification """ name = control["name"] provider = control["provider"] mod_name = provider.get("module") if not mod_name: raise InvalidActivity( "Control '{}' must have a module path".format(name)) try: importlib.import_module(mod_name) except ImportError: raise InvalidActivity("could not find Python module '{mod}' " "in control '{name}'".format( mod=mod_name, name=name))
def random_host(host_list: list, num_target: int): new_host_list = host_list[:] if num_target <= 0 or num_target > len(new_host_list): raise InvalidActivity("Number of target is not correct") try: nb_remove_target = len(new_host_list) - int(num_target) if nb_remove_target > 0: for i in range(nb_remove_target): rand = randint(0, len(new_host_list) - 1) new_host_list.pop(rand) except Exception: raise InvalidActivity("Could not generate host list") return new_host_list
def check_regex_pattern(tolerance: Tolerance): """ Check the regex pattern of a tolerance and raise :exc:`InvalidActivity` when the pattern is missing or invalid (meaning, cannot be compiled by the Python regex engine). """ if "pattern" not in tolerance: raise InvalidActivity( "hypothesis regex probe tolerance must have a `pattern` key") pattern = tolerance["pattern"] try: re.compile(pattern) except TypeError: raise InvalidActivity( "hypothesis probe tolerance pattern {} has an invalid type".format( pattern)) except re.error as e: raise InvalidActivity( "hypothesis probe tolerance pattern {} seems invalid: {}".format( e.pattern, e.msg))
def validate_python_control(control: Control): """ Verify that a control block matches the specification """ name = control["name"] provider = control["provider"] mod_name = provider.get("module") if not mod_name: raise InvalidActivity( "Control '{}' must have a module path".format(name)) try: importlib.import_module(mod_name) except ImportError: logger.warning("Could not find Python module '{mod}' " "in control '{name}'. Did you install the Python " "module? The experiment will carry on running " "nonetheless.".format(mod=mod_name, name=name))
def ensure_hypothesis_is_valid(experiment: Experiment): """ Validates that the steady state hypothesis entry has the expected schema or raises :exc:`InvalidExperiment` or :exc:`InvalidActivity`. """ hypo = experiment.get("steady-state-hypothesis") if hypo is None: return if not hypo.get("title"): raise InvalidExperiment("hypothesis requires a title") probes = hypo.get("probes") if probes: for probe in probes: ensure_activity_is_valid(probe) if "tolerance" not in probe: raise InvalidActivity("hypothesis probe must have a tolerance entry") ensure_hypothesis_tolerance_is_valid(probe["tolerance"])
def ensure_activity_is_valid(activity: Activity): """ Goes through the activity and checks certain of its properties and raise :exc:`InvalidActivity` whenever one does not respect the expectations. An activity must at least take the following key: * `"type"` the kind of activity, one of `"python"`, `"process"` or `"http"` Depending on the type, an activity requires a variety of other keys. In all failing cases, raises :exc:`InvalidActivity`. """ if not activity: raise InvalidActivity("empty activity is no activity") # when the activity is just a ref, there is little to validate ref = activity.get("ref") if ref is not None: if not isinstance(ref, str) or ref == '': raise InvalidActivity( "reference to activity must be non-empty strings") return activity_type = activity.get("type") if not activity_type: raise InvalidActivity("an activity must have a type") if activity_type not in ("probe", "action"): raise InvalidActivity( "'{t}' is not a supported activity type".format(t=activity_type)) if not activity.get("name"): raise InvalidActivity("an activity must have a name") provider = activity.get("provider") if not provider: raise InvalidActivity("an activity requires a provider") provider_type = provider.get("type") if not provider_type: raise InvalidActivity("a provider must have a type") if provider_type not in ("python", "process", "http"): raise InvalidActivity( "unknown provider type '{type}'".format(type=provider_type)) if not activity.get("name"): raise InvalidActivity("activity must have a name (cannot be empty)") timeout = activity.get("timeout") if timeout is not None: if not isinstance(timeout, numbers.Number): raise InvalidActivity("activity timeout must be a number") pauses = activity.get("pauses") if pauses is not None: before = pauses.get("before") if before is not None and not isinstance(before, numbers.Number): raise InvalidActivity("activity before pause must be a number") after = pauses.get("after") if after is not None and not isinstance(after, numbers.Number): raise InvalidActivity("activity after pause must be a number") if "background" in activity: if not isinstance(activity["background"], bool): raise InvalidActivity("activity background must be a boolean") if provider_type == "python": validate_python_activity(activity) elif provider_type == "process": validate_process_activity(activity) elif provider_type == "http": validate_http_activity(activity)
def chaosansible_run( host_list: list = ("localhost"), configuration: Configuration = None, facts: bool = False, become: bool = False, run_once: bool = False, ansible: dict = {}, num_target: str = "all", secrets: Secrets = None, ): """ Run a task through ansible and eventually gather facts from host """ # Check for correct inputs if ansible: if ansible.get("module") is None: raise InvalidActivity("No ansible module defined") if ansible.get("args") is None: raise InvalidActivity("No ansible module args defined") configuration = configuration or {} # Ansible configuration elements module_path = configuration.get("ansible_module_path") become_user = configuration.get("ansible_become_user") ssh_key_path = configuration.get("ansible_ssh_private_key") ansible_user = configuration.get("ansible_user") become_ask_pass = configuration.get("become_ask_pass") ssh_extra_args = configuration.get("ansible_ssh_extra_args") context.CLIARGS = ImmutableDict( connection="smart", verbosity=0, module_path=module_path, forks=10, become=become, become_method="sudo", become_user=become_user, check=False, diff=False, private_key_file=ssh_key_path, remote_user=ansible_user, ssh_extra_args=ssh_extra_args, ) # Update host_list regarding the number of desired target. # Need to generate a new host-list because after being update # and will be used later if num_target != "all": new_host_list = random_host(host_list, int(num_target)) else: new_host_list = host_list[:] # Create an inventory sources = ",".join(new_host_list) if len(new_host_list) == 1: sources += "," loader = DataLoader() inventory = InventoryManager(loader=loader, sources=sources) # Instantiate callback for storing results results_callback = ResultsCollectorJSONCallback() variable_manager = VariableManager(loader=loader, inventory=inventory) if become_ask_pass: passwords = dict(become_pass=become_ask_pass) else: passwords = None # Ansible taskmanager tqm = TaskQueueManager( inventory=inventory, variable_manager=variable_manager, loader=loader, passwords=passwords, stdout_callback=results_callback, run_additional_callbacks=False, ) # Ansible playbook play_source = dict( name="Ansible Play", hosts=new_host_list, gather_facts=facts, tasks=[ dict( name="facts", action=dict(module="debug", args=dict(var="ansible_facts")), ), ], ) # In cas we only want to gather facts if ansible: module = ansible.get("module") args = ansible.get("args") play_source["tasks"].append( dict( name="task", run_once=run_once, action=dict(module=module, args=args), register="shell_out", ) ) # Create an ansible playbook play = Play().load(play_source, variable_manager=variable_manager, loader=loader) # Run it try: result = tqm.run(play) finally: tqm.cleanup() if loader: loader.cleanup_all_tmp_files() # Remove ansible tmpdir shutil.rmtree(C.DEFAULT_LOCAL_TMP, True) if len(results_callback.host_failed) > 0: print("Ansible error(s): ") for error in results_callback.host_failed: print(results_callback.host_failed[error].__dict__) raise FailedActivity("Failed to run ansible task") elif len(results_callback.host_unreachable) > 0: print("Unreachable host(s): ") for error in results_callback.host_unreachable: print(error) raise FailedActivity("At least one target is down") else: results = {} for host, result in results_callback.host_ok.items(): results[host] = result return json.dumps(results)
def validate_python_activity(activity: Activity): # noqa: C901 """ Validate a Python activity. A Python activity requires: * a `"module"` key which is an absolute Python dotted path for a Python module this process can import * a `func"` key which is the name of a function in that module The `"arguments"` activity key must match the function's signature. In all failing cases, raises :exc:`InvalidActivity`. This should be considered as a private function. """ name = activity["name"] provider = activity["provider"] mod_name = provider.get("module") if not mod_name: raise InvalidActivity("a Python activity must have a module path") func = provider.get("func") if not func: raise InvalidActivity("a Python activity must have a function name") try: mod = importlib.import_module(mod_name) except ImportError: raise InvalidActivity("could not find Python module '{mod}' " "in activity '{name}'".format(mod=mod_name, name=name)) found_func = False arguments = provider.get("arguments", {}) candidates = set(inspect.getmembers(mod, inspect.isfunction)).union( inspect.getmembers(mod, inspect.isbuiltin)) for (name, cb) in candidates: if name == func: found_func = True # let's try to bind the activity's arguments with the function # signature see if they match sig = inspect.signature(cb) try: # config and secrets are provided through specific parameters # to an activity that needs them. However, they are declared # out of band of the `arguments` mapping. Here, we simply # ensure the signature of the activity is valid by injecting # fake `configuration` and `secrets` arguments into the mapping args = arguments.copy() if "secrets" in sig.parameters: args["secrets"] = None if "configuration" in sig.parameters: args["configuration"] = None sig.bind(**args) except TypeError as x: # I dislike this sort of lookup but not sure we can # differentiate them otherwise msg = str(x) if "missing" in msg: arg = msg.rsplit(":", 1)[1].strip() raise InvalidActivity( "required argument {arg} is missing from " "activity '{name}'".format(arg=arg, name=name)) elif "unexpected" in msg: arg = msg.rsplit(" ", 1)[1].strip() raise InvalidActivity( "argument {arg} is not part of the " "function signature in activity '{name}'".format( arg=arg, name=name)) else: # another error? let's fail fast raise break if not found_func: raise InvalidActivity( "'{mod}' does not expose '{func}' in activity '{name}'".format( mod=mod_name, func=func, name=name))
def validate_control(control: Control) -> None: if "should-not-be-here" in control: raise InvalidActivity("invalid key on control")
def ensure_experiment_is_valid(experiment: Experiment): """ A chaos experiment consists of a method made of activities to carry sequentially. There are two kinds of activities: * probe: detecting the state of a resource in your system or external to it There are two kinds of probes: `steady` and `close` * action: an operation to apply against your system Usually, an experiment is made of a set of `steady` probes that ensure the system is sound to carry further the experiment. Then, an action before another set of of ̀close` probes to sense the state of the system post-action. This function raises :exc:`InvalidExperiment`, :exc:`InvalidProbe` or :exc:`InvalidAction` depending on where it fails. """ logger.info("Validating the experiment's syntax") if not experiment: raise InvalidExperiment("an empty experiment is not an experiment") if not experiment.get("title"): raise InvalidExperiment("experiment requires a title") if not experiment.get("description"): raise InvalidExperiment("experiment requires a description") tags = experiment.get("tags") if tags: if list(filter(lambda t: t == '' or not isinstance(t, str), tags)): raise InvalidExperiment( "experiment tags must be a non-empty string") validate_extensions(experiment) config = load_configuration(experiment.get("configuration", {})) load_secrets(experiment.get("secrets", {}), config) ensure_hypothesis_is_valid(experiment) method = experiment.get("method") if not method: raise InvalidExperiment("an experiment requires a method with " "at least one activity") for activity in method: ensure_activity_is_valid(activity) # let's see if a ref is indeed found in the experiment ref = activity.get("ref") if ref and not lookup_activity(ref): raise InvalidActivity("referenced activity '{r}' could not be " "found in the experiment".format(r=ref)) rollbacks = experiment.get("rollbacks", []) for activity in rollbacks: ensure_activity_is_valid(activity) warn_about_deprecated_features(experiment) validate_controls(experiment) logger.info("Experiment looks valid")