def test_run_process_probe_can_timeout(): probe = probes.ProcProbe probe["provider"]["timeout"] = 0.0001 with pytest.raises(ActivityFailed) as exc: run_activity(probes.ProcProbe, config.EmptyConfig, experiments.Secrets).decode("utf-8") assert "activity took too long to complete" in str(exc.value)
def test_run_http_probe_can_expect_failure(): with requests_mock.mock() as m: m.post("http://example.com", status_code=404, text="Not found!") probe = probes.HTTPProbe.copy() probe["provider"]["expected_status"] = 404 try: run_activity(probe, config.EmptyConfig, experiments.Secrets) except ActivityFailed: pytest.fail("activity should not have failed")
def test_run_http_probe_should_return_parsed_json_value(): with requests_mock.mock() as m: headers = {"Content-Type": "application/json"} m.post("http://example.com", json=["well done"], headers=headers) result = run_activity(probes.HTTPProbe, config.EmptyConfig, experiments.Secrets) assert result["body"] == ["well done"]
def test_run_process_probe_should_return_raw_value(): v = "Python {v}\n".format(v=sys.version.split(" ")[0]) result = run_activity(probes.ProcProbe, config.EmptyConfig, experiments.Secrets) assert type(result) is tuple assert result == (0, v, '')
def test_run_process_probe_should_pass_arguments_in_array(): args = "['-c', '--empty', '--number', '1', '--string', 'with spaces', '--string', 'a second string with the same option']\n" result = run_activity(probes.ProcEchoArrayProbe, config.EmptyConfig, experiments.Secrets) assert type(result) is tuple assert result == (0, args, '')
def test_run_http_probe_must_be_serializable_to_json(): with requests_mock.mock() as m: headers = {"Content-Type": "application/json"} m.post("http://example.com", json=["well done"], headers=headers) result = run_activity(probes.HTTPProbe, config.EmptyConfig, experiments.Secrets) assert json.dumps(result) is not None
def test_run_http_probe_can_retry(): """ this test embeds a fake HTTP server to test the retry part it can't be easily tested with libraries like requests_mock or responses we could mock urllib3 retry mechanism as it is used in the requests library but it implies to understand how requests works which is not the idea of this test in this test, the first call will lead to a ConnectionAbortedError and the second will work """ class MockServerRequestHandler(BaseHTTPRequestHandler): """ mock of a real HTTP server to simulate the behavior of a connection aborted error on first call """ call_count = 0 def do_GET(self): MockServerRequestHandler.call_count += 1 if MockServerRequestHandler.call_count == 1: raise ConnectionAbortedError self.send_response(200) self.end_headers() return # get a free port to listen on s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM) s.bind(("localhost", 0)) address, port = s.getsockname() s.close() # start the fake HTTP server in a dedicated thread on the selected port server = HTTPServer(("localhost", port), MockServerRequestHandler) t = Thread(target=server.serve_forever) t.setDaemon(True) t.start() # change probe URL to call the selected port probe = probes.PythonModuleProbeWithHTTPMaxRetries.copy() probe["provider"]["url"] = f"http://localhost:{port}" try: run_activity(probe, config.EmptyConfig, experiments.Secrets) except ActivityFailed: pytest.fail("activity should not have failed")
def test_run_process_probe_should_return_raw_value(): v = "Python {v}\n".format(v=sys.version.split(" ")[0]) result = run_activity(probes.ProcProbe, config.EmptyConfig, experiments.Secrets) assert type(result) is dict assert result["status"] == 0 assert result["stdout"] == v assert result["stderr"] == ""
def test_run_process_probe_can_pass_arguments_as_string(): args = "['-c', '--empty', '--number', '1', '--string', 'with spaces', '--string', 'a second string with the same option']\n" result = run_activity(probes.ProcEchoStrProbe, config.EmptyConfig, experiments.Secrets) assert type(result) is dict assert result["status"] == 0 assert result["stdout"] == args assert result["stderr"] == ''
def execute_activity(experiment: Experiment, probe: Probe, configuration: Configuration, secrets: Secrets) -> Run: """ Low-level wrapper around the actual activity provider call to collect some meta data (like duration, start/end time, exceptions...) during the run. """ ref = probe.get("ref") if ref: probe = lookup_activity(ref) if not probe: raise ActivityFailed( "could not find referenced activity '{r}'".format(r=ref)) with controls(level="activity", experiment=experiment, context=probe, configuration=configuration, secrets=secrets) as control: pauses = probe.get("pauses", {}) pause_before = pauses.get("before") if pause_before: time.sleep(pause_before) start = datetime.utcnow() run = {"activity": probe.copy(), "output": None} result = None try: result = run_activity(probe, configuration, secrets) run["output"] = result run["status"] = "succeeded" except ActivityFailed as x: run["status"] = "failed" run["output"] = result run["exception"] = traceback.format_exception(type(x), x, None) finally: end = datetime.utcnow() run["start"] = start.isoformat() run["end"] = end.isoformat() run["duration"] = (end - start).total_seconds() pause_after = pauses.get("after") if pause_after: time.sleep(pause_after) control.with_state(run) return run
def _(tolerance: dict, value: Any, secrets: Secrets = None) -> bool: tolerance_type = tolerance.get("type") if tolerance_type == "probe": tolerance["arguments"]["value"] = value run = run_activity(tolerance, secrets) return run["status"] == "succeeded" elif tolerance_type == "regex": target = tolerance.get("target") pattern = tolerance.get("pattern") rx = re.compile(pattern) if target: value = value.get(target, value) return rx.search(value) is not None elif tolerance_type == "jsonpath": target = tolerance.get("target") path = tolerance.get("path") count_value = tolerance.get("count", None) px = jparse.parse(path) if target: # if no target was provided, we use the tested value as-is value = value.get(target, value) if isinstance(value, bytes): value = value.decode('utf-8') if isinstance(value, str): try: value = json.loads(value) except json.decoder.JSONDecodeError: pass items = px.find(value) result = len(items) > 0 if count_value is not None: result = len(items) == count_value if "expect" in tolerance: expect = tolerance["expect"] values = [item.value for item in items] if len(values) == 1: result = expect in [values[0], values] else: result = values == expect return result
def test_run_python_probe_should_return_raw_value(): # our probe checks a file exists assert (run_activity(probes.PythonModuleProbe, config.EmptyConfig, experiments.Secrets) is True)
def test_run_http_probe_should_return_raw_text_value(): with requests_mock.mock() as m: m.post("http://example.com", text="['well done']") result = run_activity(probes.HTTPProbe, config.EmptyConfig, experiments.Secrets) assert result["body"] == "['well done']"
def _(tolerance: dict, value: Any, secrets: Secrets = None) -> bool: tolerance["arguments"]["value"] = value run = run_activity(tolerance, secrets) return run["status"] == "succeeded"
def _(tolerance: dict, value: Any, configuration: Configuration = None, secrets: Secrets = None) -> bool: tolerance_type = tolerance.get("type") if tolerance_type == "probe": tolerance["provider"]["arguments"]["value"] = value try: run_activity(tolerance, configuration, secrets) return True except ActivityFailed: return False elif tolerance_type == "regex": target = tolerance.get("target") pattern = tolerance.get("pattern") rx = re.compile(pattern) if target: value = value.get(target, value) return rx.search(value) is not None elif tolerance_type == "jsonpath": target = tolerance.get("target") path = tolerance.get("path") count_value = tolerance.get("count", None) px = jparse.parse(path) if target: # if no target was provided, we use the tested value as-is value = value.get(target, value) if isinstance(value, bytes): value = value.decode('utf-8') if isinstance(value, str): try: value = json.loads(value) except json.decoder.JSONDecodeError: pass items = px.find(value) result = len(items) > 0 if count_value is not None: result = len(items) == count_value if "expect" in tolerance: expect = tolerance["expect"] values = [item.value for item in items] if len(values) == 1: result = expect in [values[0], values] else: result = values == expect return result elif tolerance_type == "range": target = tolerance.get("target") if target: value = value.get(target, value) try: value = Decimal(value) except InvalidOperation: logger.debug("range check expects a number value") return False the_range = tolerance.get("range") min_value = the_range[0] max_value = the_range[1] return Decimal(min_value) <= value <= Decimal(max_value)
def _(tolerance: dict, value: Any, configuration: Configuration = None, secrets: Secrets = None) -> bool: tolerance_type = tolerance.get("type") if tolerance_type == "probe": tolerance["provider"]["arguments"]["value"] = value try: rtn = run_activity(tolerance, configuration, secrets) if rtn: return True else: return False except ActivityFailed: return False elif tolerance_type == "regex": target = tolerance.get("target") pattern = tolerance.get("pattern") pattern = substitute(pattern, configuration, secrets) logger.debug("Applied pattern is: {}".format(pattern)) rx = re.compile(pattern) if target: value = value.get(target, value) return rx.search(value) is not None elif tolerance_type == "jsonpath": target = tolerance.get("target") path = tolerance.get("path") count_value = tolerance.get("count", None) path = substitute(path, configuration, secrets) logger.debug("Applied jsonpath is: {}".format(path)) px = JSONPath.parse_str(path) if target: # if no target was provided, we use the tested value as-is value = value.get(target, value) if isinstance(value, bytes): value = value.decode('utf-8') if isinstance(value, str): try: value = json.loads(value) except json.decoder.JSONDecodeError: pass values = list(map(lambda m: m.current_value, px.match(value))) result = len(values) > 0 if count_value is not None: result = len(values) == count_value expect = tolerance.get("expect") if "expect" in tolerance: if not isinstance(expect, list): result = values == [expect] else: result = values == expect if result is False: if "expect" in tolerance: logger.debug("jsonpath found '{}' but expected '{}'".format( str(values), str(tolerance["expect"]))) else: logger.debug("jsonpath found '{}'".format(str(values))) return result elif tolerance_type == "range": target = tolerance.get("target") if target: value = value.get(target, value) try: value = Decimal(value) except InvalidOperation: logger.debug("range check expects a number value") return False the_range = tolerance.get("range") min_value = the_range[0] max_value = the_range[1] return Decimal(min_value) <= value <= Decimal(max_value)
def load_dynamic_configuration( config: Configuration, secrets: Secrets = None ) -> Configuration: """ This is for loading a dynamic configuration if exists. The dynamic config is a regular activity (probe) in the configuration section. If there's a use-case for setting a configuration dynamically right before the experiment is starting. It executes the probe, and then the return value of this probe will be the config you wish to set. The dictionary needs to have a key named `type` and as a value `probe`, alongside the rest of the probe props. (No need for the `tolerance` key). For example: ```json "some_dynamic_config": { "name": "some config probe", "type": "probe", "provider": { "type": "python", "module": "src.probes", "func": "config_probe", "arguments": { "arg1":"arg1" } } } ``` `some_dynamic_config` will be set with the return value of the function config_probe. Side Note: the probe type can be the same as a regular probe can be, python, process or http. The config argument contains all the configurations of the experiment including the raw config_probe configuration that can be dynamically injected. The configurations contain as well all the env vars after they are set in `load_configuration`. The `secrets` argument contains all the secrets of the experiment. For `process` probes, the stdout value (stripped of endlines) is stored into the configuration. For `http` probes, the `body` value is stored. For `python` probes, the output of the function will be stored. We do not stop on errors but log a debug message and do not include the key into the result dictionary. """ # we delay this so that the configuration module can be imported leanly # from elsewhere from chaoslib.activity import run_activity conf = {} secrets = secrets or {} had_errors = False logger.debug("Loading dynamic configuration...") for (key, value) in config.items(): if not (isinstance(value, dict) and value.get("type") == "probe"): conf[key] = config.get(key, value) continue # we have a dynamic config name = value.get("name") provider_type = value["provider"]["type"] value["provider"]["secrets"] = deepcopy(secrets) try: output = run_activity(value, conf, secrets) except Exception: had_errors = True logger.debug(f"Failed to load configuration '{name}'", exc_info=True) continue if provider_type == "python": conf[key] = output elif provider_type == "process": if output["status"] != 0: had_errors = True logger.debug( f"Failed to load configuration dynamically " f"from probe '{name}': {output['stderr']}" ) else: conf[key] = output.get("stdout", "").strip() elif provider_type == "http": conf[key] = output.get("body") if had_errors: logger.warning( "Some of the dynamic configuration failed to be loaded." "Please review the log file for understanding what happened." ) return conf