Exemple #1
0
 def test_iterate_command_lists_no_commands(self):
     """Test iterate_command_lists when the yaml has no commands."""
     yaml_dict = load(TestHelpers.I_CANT_BELIEVE_THAT_VALIDATES)
     gen = h.iterate_command_lists(yaml_dict)
     count = 0
     for _ in gen:
         count = count + 1
     self.assertEqual(count, 0)
Exemple #2
0
 def test_iterate_command_lists(self):
     """test iterate_command_lists."""
     yaml_dict = load(TestRulebreaker.RULEBREAKER.format(inject_here=""))
     gen = h.iterate_command_lists(yaml_dict)
     count = 0
     for _ in gen:
         count = count + 1
     self.assertEqual(count, 12)
Exemple #3
0
def required_expansions_write(yaml: dict) -> List[LintError]:
    """Require that subprocess.exec functions that consume evergreen scripts correctly bootstrap the prelude."""

    # This logic is well and truly awful.
    # Here's the problem:
    # 1. Evergreen functions can have a dict or list definition. Functions
    # with a dict definition, that is:
    #   "f_my_fn": &f_my_fn
    #     command: shell.exec
    # can called either as "- func: f_my_fn" or as *f_my_fn. The former syntax
    # can have arguments passed into it. The latter syntax,
    # i.e. the use of the YAML anchor, is used to workaround Evergreen not
    # allowing functions to call other functions. Arguments cannot be passed in
    # when the YAML anchor syntax is used.

    # This poses a problem: if the user calls a function with a dict definition,
    # and that function has arguments passed in, and that function calls
    # subprocess.exec, then the function will not have its expansions populated
    # in the external script as expected. It must be defined as a list, and
    # incorporate an expansions.write call, or not be called with args.
    # See Resolution 1.

    # Functions with a list definition, that is:
    #   "f_my_fn2": &f_my_fn2
    #     - command: shell.exec
    #     - command: shell.exec
    # cannot use the YAML anchor syntax (this is an evergreen validation
    # error). If these functions call subprocess.exec, then they will only work
    # if expansions.write is called before subprocess.exec. See Resolution 2.
    #
    # 2. Expansions can be defined/changed at any step of the way by Evergreen,
    # function arguments, build variants, Evergreen projects, tasks. Expansions
    # written to disk MUST be up to date. See Resolution 2
    # 3. Expansions be updated after expansions.update, but before
    # subprocess.exec. See Resolution 3.

    # Resolutions:
    # Given the above, here are the checks we need to perform
    # 1. Any function defined with a dict definition that calls subprocess.exec
    # MUST NOT be called with arguments.
    # 2. expansions.write MUST be called prior to invoking subprocess.exec for
    # the first time in any command list.
    # (i.e. in tasks: commands, setup_task, setup_group, teardown_task,
    # teardown_group, timeout,, globally: pre, post, timeout, functions with
    # list definitions)
    # When paired with Resolution 3, this works all the time (but does
    # sometimes require redundant expansions.write calls)
    # 3. Every invocation of expansions.update MUST be immediately followed by
    # expansions.write

    # 4. It makes sense to place your expansions.write call in a dict
    # definition function, that way you can call it with either syntax. So, in
    # the above passage where I've mentioned "expansions.write", we must not
    # only check for a properly formed expansions.write call, we must also
    # check for a function that calls expansions.write. For reference, a
    # properly formed expansions.write call looks like this:
    #   command: expansions.write
    #   params:
    #     file: expansions.yml
    #     redacted: true
    # A function containing the above as a dict definition is treated as
    # equivalent to calling expansions.write
    #
    # 5. Furthermore, above mentions of subprocess.exec MUST be applied only to
    # subprocess.exec invocations that call scripts in the evergreen directory.
    #
    # 6. And because that's not complicated enough, any functions that are
    # dict-defined with subprocess.exec must be treated as equivalent to
    # subprocess.exec on its own.
    # 7. Functions that call expansions.update, and are dict-defined MUST
    # require an expansions.update call after they are called, regardless of
    # syntax
    # 8. timeout.update can also affect expansion values, and all of the rules
    # above need to applied equally to timeout.update

    # These are functions that invoke expansions.write in a dict defintion,
    # as described in Resolution 4.
    expansions_write_fns: Set[str] = set()
    # These are functions whose invocations must be checked for Resolution 1.
    subprocess_exec_fns: Set[str] = set()
    # And these are for Resolution 7
    expansions_update_fns: Set[str] = set()
    # and Resolution 8
    timeout_update_fns: Set[str] = set()

    def _out_message_dangerous_function(context: str) -> LintError:
        return f"{context} cannot safely take arguments. Call expansions.write with params: file: expansions.yml; redacted: true, (or use one of these functions: {list(expansions_write_fns)}) in the function, or do not pass arguments to it."

    def _out_message_expansions_update(context: str,
                                       fname: str = "expansions.update"
                                       ) -> LintError:
        return f"{context} is an {fname} command that is not immediately followed by an expansions.write call. Always call expansions.write with params: file: expansions.yml; redacted: true, (or use one of these functions: {list(expansions_write_fns)}) after calling {fname}."

    def _out_message_subprocess(context: str) -> LintError:
        return f"{context} calls an evergreen shell script without a preceding expansions.write call. Always call expansions.write with params: file: expansions.yml; redacted: true, (or use one of these functions: {list(expansions_write_fns)}) before calling an evergreen shell script via subprocess.exec."

    def _is_expansions_write_or_fn(command: dict) -> bool:
        return helpers.match_expansions_write(command) or (
            "func" in command and command["func"] in expansions_write_fns)

    def _is_subprocess_exec_or_fn(command: dict) -> bool:
        return helpers.match_subprocess_exec(command) or (
            "func" in command and command["func"] in subprocess_exec_fns)

    def _is_expansions_update_or_fn(command: dict) -> bool:
        return helpers.match_expansions_update(command) or (
            "func" in command and command["func"] in expansions_update_fns)

    def _is_timeout_update_or_fn(command: dict) -> bool:
        return helpers.match_timeout_update(command) or (
            "func" in command and command["func"] in timeout_update_fns)

    def _context_add_fn(context: str, command: Optional[dict]) -> str:
        if command and "func" in command:
            return f'{context}, (function call: {command["func"]})'

        return context

    def _check_command_list(context: str,
                            commands: List[dict]) -> List[LintError]:
        out: List[LintError] = []
        first_subprocess: int = None
        first_subprocess_cmd: dict = None
        first_exp_write: int = None
        warned_subprocess = False
        for idx, command in enumerate(commands):
            if first_subprocess is None and _is_subprocess_exec_or_fn(command):
                first_subprocess = idx
                first_subprocess_cmd = command

            elif first_exp_write is None and _is_expansions_write_or_fn(
                    command):
                first_exp_write = idx

            if not warned_subprocess and _is_subprocess_exec_or_fn(
                    command) and first_exp_write is None:
                out.append(
                    _out_message_subprocess(
                        _context_add_fn(f"{context}, command {idx}", command)))
                # only warn for the first instance of this per command list.
                # Once you resolve the first instance, the Resolution 3 and
                # Resolution 8 checks below handle the rest of the errors.
                warned_subprocess = True

            if (_is_expansions_update_or_fn(command)
                    or _is_timeout_update_or_fn(command)):
                # Resolution 3 and Resolution 8
                if len(commands) <= idx + 1 or not _is_expansions_write_or_fn(
                        commands[idx + 1]):
                    if _is_expansions_update_or_fn(command):
                        out.append(
                            _out_message_expansions_update(
                                _context_add_fn(f"{context}, command {idx}",
                                                command)))
                    elif _is_timeout_update_or_fn(command):
                        out.append(
                            _out_message_expansions_update(
                                _context_add_fn(f"{context}, command {idx}",
                                                command), "timeout.update"))

        if not warned_subprocess and first_subprocess is not None and first_exp_write is not None and first_subprocess < first_exp_write:
            out.append(
                _out_message_subprocess(
                    _context_add_fn(f"{context}, command {first_subprocess}",
                                    first_subprocess_cmd)))

        return out

    out: List[LintError] = []
    if "functions" in yaml:
        for fname, body in yaml["functions"].items():
            if isinstance(body, dict):
                # assemble the list of functions whose bodies are the expected
                # expansions.write call. (Resolution 4)
                if helpers.match_expansions_write(body):
                    expansions_write_fns.add(fname)
                # assemble the list of functions that must never be called
                # with arguments (Resolution 1)
                elif helpers.match_subprocess_exec(body):
                    subprocess_exec_fns.add(fname)
                # Resolution 7
                elif helpers.match_expansions_update(body):
                    expansions_update_fns.add(fname)
                # Resolution 8
                elif helpers.match_timeout_update(body):
                    timeout_update_fns.add(fname)

    for context, commands in iterate_command_lists(yaml):
        if isinstance(commands, dict):
            continue
        out += _check_command_list(context, commands)

    for context, command, _ in iterate_fn_calls_context(yaml):
        if command[
                "func"] in subprocess_exec_fns and "vars" in command and command[
                    "vars"]:
            out.append(_out_message_dangerous_function(context))

    return out