Exemple #1
0
def create_vault_client(configuration: Configuration = None):
    """
    Initialize a Vault client from either a token or an approle.
    """
    client = None
    if HAS_HVAC:
        url = configuration.get("vault_addr")
        client = hvac.Client(url=url)

        client.secrets.kv.default_kv_version = str(configuration.get(
            "vault_kv_version", "2"))
        logger.debug(
            "Using Vault secrets KV version {}".format(
                client.secrets.kv.default_kv_version))

        if "vault_token" in configuration:
            client.token = configuration.get("vault_token")
        elif "vault_role_id" in configuration and \
             "vault_role_secret" in configuration:
            role_id = configuration.get("vault_role_id")
            role_secret = configuration.get("vault_role_secret")

            try:
                app_role = client.auth_approle(role_id, role_secret)
            except Exception as ve:
                raise InvalidExperiment(
                    "Failed to connect to Vault with the AppRole: {}".format(
                        str(ve)))

            client.token = app_role['auth']['client_token']
        elif "vault_sa_role" in configuration:
            sa_token_path = configuration.get(
                "vault_sa_token_path", "") or \
                "/var/run/secrets/kubernetes.io/serviceaccount/token"

            mount_point = configuration.get(
                "vault_k8s_mount_point", "kubernetes")

            try:
                with open(sa_token_path) as sa_token:
                    jwt = sa_token.read()
                    role = configuration.get("vault_sa_role")
                    client.auth_kubernetes(role=role,
                                           jwt=jwt,
                                           use_token=True,
                                           mount_point=mount_point)
            except IOError:
                raise InvalidExperiment(
                    "Failed to get service account token at: {path}".format(
                        path=sa_token_path))
            except Exception as e:
                raise InvalidExperiment(
                    "Failed to connect to Vault using service account with "
                    "errors: '{errors}'".format(errors=str(e)))

    return client
Exemple #2
0
def load_secrets_from_env(
    secrets_info: Dict[str, Dict[str, str]],
    configuration: Configuration = None,
    extra_vars: Dict[str, Any] = None,
) -> Secrets:
    env = os.environ
    secrets = {}

    for (target, keys) in secrets_info.items():
        secrets[target] = {}

        for (key, value) in keys.items():
            if isinstance(value, dict) and value.get("type") == "env":
                env_key = value["key"]
                if (env_key not in env) and (key not in extra_vars.get(
                        "target", {})):
                    raise InvalidExperiment(
                        "Secrets make reference to an environment key "
                        "that does not exist: {}".format(env_key))
                secrets[target][key] = extra_vars.get(target, {}).get(
                    key, env.get(env_key))

        if not secrets[target]:
            secrets.pop(target)

    return secrets
Exemple #3
0
def parse_experiment_from_http(response: requests.Response) -> Experiment:
    """
    Parse the given experiment from the request's `response`.
    """
    content_type = response.headers.get("Content-Type")

    if 'application/json' in content_type:
        return response.json()
    elif 'application/x-yaml' in content_type or 'text/yaml' in content_type:
        try:
            return yaml.safe_load(response.text)
        except yaml.YAMLError as ye:
            raise InvalidSource("Failed parsing YAML experiment: {}".format(
                str(ye)))
    elif 'text/plain' in content_type:
        content = response.text
        try:
            return json.loads(content)
        except JSONDecodeError:
            try:
                return yaml.safe_load(content)
            except yaml.YAMLError:
                pass

    raise InvalidExperiment(
        "only files with json, yaml or yml extensions are supported")
Exemple #4
0
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_extensions(experiment: Experiment):
    """
    Validate that extensions respect the specification.
    """
    extensions = experiment.get("extensions")
    if not extensions:
        return

    for ext in extensions:
        ext_name = ext.get("name")
        if not ext_name or not ext_name.strip():
            raise InvalidExperiment("All extensions require a non-empty name")
def load_configuration(config_info: Dict[str, str]) -> Configuration:
    """
    Load the configuration. The `config_info` parameter is a mapping from
    key strings to value as strings or dictionaries. In the former case, the
    value is used as-is. In the latter case, if the dictionary has a key named
    `type` alongside a key named `key`.
    An optional default value is accepted for dictionary value with a key named
    `default`. The default value will be used only if the environment variable
    is not defined.


    Here is a sample of what it looks like:

    ```
    {
        "cert": "/some/path/file.crt",
        "token": {
            "type": "env",
            "key": "MY_TOKEN"
        },
        "host": {
            "type": "env",
            "key": "HOSTNAME",
            "default": "localhost"
        }
    }
    ```

    The `cert` configuration key is set to its string value whereas the `token`
    configuration key is dynamically fetched from the `MY_TOKEN` environment
    variable. The `host` configuration key is dynamically fetched from the
    `HOSTNAME` environment variable, but if not defined, the default value
    `localhost` will be used instead.
    """
    logger.debug("Loading configuration...")
    env = os.environ
    conf = {}

    for (key, value) in config_info.items():
        if isinstance(value, dict) and "type" in value:
            if value["type"] == "env":
                env_key = value["key"]
                env_default = value.get("default")
                if (env_key not in env) and (env_default is None):
                    raise InvalidExperiment(
                        "Configuration makes reference to an environment key"
                        " that does not exist: {}".format(env_key))
                conf[key] = env.get(env_key, env_default)
        else:
            conf[key] = value

    return conf
Exemple #7
0
def parse_experiment_from_file(path: str) -> Experiment:
    """
    Parse the given experiment from `path` and return it.
    """
    with io.open(path) as f:
        p, ext = os.path.splitext(path)
        if ext in (".yaml", ".yml"):
            return yaml.load(f)
        elif ext == ".json":
            return json.load(f)

    raise InvalidExperiment(
        "only files with json, yaml or yml extensions are supported")
Exemple #8
0
def parse_experiment_from_http(response: requests.Response) -> Experiment:
    """
    Parse the given experiment from the request's `response`.
    """
    headers = response.headers
    content_type = response.headers.get("Content-Type")

    if 'application/json' in content_type:
        return response.json()
    elif 'application/x-yaml' in content_type or 'text/yaml' in content_type:
        return yaml.load(response.text)

    raise InvalidExperiment(
        "only files with json, yaml or yml extensions are supported")
Exemple #9
0
def parse_experiment_from_file(path: str) -> Experiment:
    """
    Parse the given experiment from `path` and return it.
    """
    with io.open(path) as f:
        p, ext = os.path.splitext(path)
        if ext in (".yaml", ".yml"):
            try:
                return yaml.safe_load(f)
            except yaml.YAMLError as ye:
                raise InvalidSource(
                    "Failed parsing YAML experiment: {}".format(str(ye)))
        elif ext == ".json":
            return json.load(f)

    raise InvalidExperiment(
        "only files with json, yaml or yml extensions are supported")
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"])
Exemple #11
0
def load_configuration(config_info: Dict[str, str]) -> Configuration:
    """
    Load the configuration. The `config_info` parameter is a mapping from
    key strings to value as strings or dictionaries. In the former case, the
    value is used as-is. In the latter case, if the dictionary has a key named
    `type` alongside a key named `key`.

    Here is a sample of what it looks like:

    ```
    {
        "cert": "/some/path/file.crt",
        "token": {
            "type": "env",
            "key": "MY_TOKEN"
        }
    }
    ```

    The `cert` configuration key is set to its string value whereas the `token`
    configuration key is dynamically fetched from the `MY_TOKEN` environment
    variable.
    """
    logger.debug("Loading configuration...")
    env = os.environ
    conf = {}

    for (key, value) in config_info.items():
        if isinstance(value, dict) and "type" in value:
            if value["type"] == "env":
                env_key = value["key"]
                if env_key not in env:
                    raise InvalidExperiment(
                        "Configuration makes reference to an environment key"
                        " that does not exist: {}".format(env_key))
                conf[key] = env.get(env_key)
        else:
            conf[key] = value

    return conf
Exemple #12
0
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")
def load_configuration(
    config_info: Dict[str, str], extra_vars: Dict[str, Any] = None
) -> Configuration:
    """
    Load the configuration. The `config_info` parameter is a mapping from
    key strings to value as strings or dictionaries. In the former case, the
    value is used as-is. In the latter case, if the dictionary has a key named
    `type` alongside a key named `key`.
    An optional default value is accepted for dictionary value with a key named
    `default`. The default value will be used only if the environment variable
    is not defined.


    Here is a sample of what it looks like:

    ```
    {
        "cert": "/some/path/file.crt",
        "token": {
            "type": "env",
            "key": "MY_TOKEN"
        },
        "host": {
            "type": "env",
            "key": "HOSTNAME",
            "default": "localhost"
        }
    }
    ```

    The `cert` configuration key is set to its string value whereas the `token`
    configuration key is dynamically fetched from the `MY_TOKEN` environment
    variable. The `host` configuration key is dynamically fetched from the
    `HOSTNAME` environment variable, but if not defined, the default value
    `localhost` will be used instead.

    When `extra_vars` is provided, it must be a dictionnary where keys map
    to configuration key. The values from `extra_vars` always override the
    values from the experiment itself. This is useful to the Chaos Toolkit
    CLI mostly to allow overriding values directly from cli arguments. It's
    seldom required otherwise.
    """
    logger.debug("Loading configuration...")
    env = os.environ
    extra_vars = extra_vars or {}
    conf = {}

    for (key, value) in config_info.items():
        if isinstance(value, dict) and "type" in value:
            if value["type"] == "env":
                env_key = value["key"]
                env_default = value.get("default")
                if (
                    (env_key not in env)
                    and (env_default is None)
                    and (key not in extra_vars)
                ):
                    raise InvalidExperiment(
                        "Configuration makes reference to an environment key"
                        " that does not exist: {}".format(env_key)
                    )
                conf[key] = extra_vars.get(key, env.get(env_key, env_default))
        else:
            conf[key] = extra_vars.get(key, value)

    return conf