Files
mongo/buildscripts/evglint/rules.py
2021-03-25 16:18:55 +00:00

222 lines
8.8 KiB
Python

"""Lint rules."""
import re
from typing import Dict, List
from buildscripts.evglint.model import LintRule, LintError
from buildscripts.evglint.helpers import iterate_commands
def no_keyval_inc(yaml: dict) -> List[LintError]:
"""Prevent usage of keyval.inc."""
def _out_message(context: str) -> LintError:
return f"{context} includes keyval.inc, which is not permitted. Do not use keyval.inc."
out: List[LintError] = []
for context, command in iterate_commands(yaml):
if "command" in command and command["command"] == "keyval.inc":
out.append(_out_message(context))
return out
def shell_exec_explicit_shell(yaml: dict) -> List[LintError]:
"""Require explicitly specifying shell in uses of shell.exec."""
def _out_message(context: str) -> LintError:
return f"{context} is a shell.exec command without an explicitly declared shell. You almost certainly want to add 'shell: bash' to the parameters list."
out: List[LintError] = []
for context, command in iterate_commands(yaml):
if "command" in command and command["command"] == "shell.exec":
if "params" not in command or "shell" not in command["params"]:
out.append(_out_message(context))
return out
SHELL_COMMANDS = ["subprocess.exec", "shell.exec"]
def no_working_dir_on_shell(yaml: dict) -> List[LintError]:
"""Do not allow working_dir to be set on shell.exec, subprocess.*."""
def _out_message(context: str, cmd: str) -> LintError:
return f"{context} is a {cmd} command with a working_dir parameter. Do not set working_dir, instead `cd` into the directory in the shell script."
out: List[LintError] = []
for context, command in iterate_commands(yaml):
if "command" in command and command["command"] in SHELL_COMMANDS:
if "params" in command and "working_dir" in command["params"]:
out.append(_out_message(context, command["command"]))
return out
FUNCTION_NAME = "^f_[a-z][A-Za-z0-9_]*"
FUNCTION_NAME_RE = re.compile(FUNCTION_NAME)
def invalid_function_name(yaml: dict) -> List[LintError]:
"""Enforce naming convention on functions."""
def _out_message(context: str) -> LintError:
return f"Function '{context}' must have a name matching '{FUNCTION_NAME}'"
if "functions" not in yaml:
return []
out: List[LintError] = []
for fname in yaml["functions"].keys():
if not FUNCTION_NAME_RE.fullmatch(fname):
out.append(_out_message(fname))
return out
def no_shell_exec(yaml: dict) -> List[LintError]:
"""Do not allow shell.exec. Users should use subprocess.exec instead."""
def _out_message(context: str) -> LintError:
return (f"{context} is a shell.exec command, which is forbidden. "
"Extract your shell script out of the YAML and into a .sh file "
"in directory 'evergreen', and use subprocess.exec instead.")
out: List[LintError] = []
for context, command in iterate_commands(yaml):
if "command" in command and command["command"] == "shell.exec":
out.append(_out_message(context))
return out
def no_multiline_expansions_update(yaml: dict) -> List[LintError]:
"""Forbid multi-line values in expansion.updates parameters."""
def _out_message(context: str, idx: int) -> LintError:
return (f"{context}, key-value pair {idx} is an expansions.update "
"command a multi-line values, which is forbidden. For "
"long-form values, prefer expansions.write.")
out: List[LintError] = []
for context, command in iterate_commands(yaml):
if "command" in command and command["command"] == "expansions.update":
if "params" in command and "updates" in command["params"]:
for idx, item in enumerate(command["params"]["updates"]):
if "value" in item and "\n" in item["value"]:
out.append(_out_message(context, idx))
return out
BUILD_PARAMETER = "[a-z][a-z0-9_]*"
BUILD_PARAMETER_RE = re.compile(BUILD_PARAMETER)
def invalid_build_parameter(yaml: dict) -> List[LintError]:
"""Require that parameters obey a naming convention and have a description."""
def _out_message_key(idx: int) -> LintError:
return f"Build parameter, pair {idx}, key must match '{BUILD_PARAMETER}'."
def _out_message_description(idx: int) -> LintError:
return f"Build parameter, pair {idx}, must have a description."
if "parameters" not in yaml:
return []
out: List[LintError] = []
for idx, param in enumerate(yaml["parameters"]):
if "key" not in param or not BUILD_PARAMETER_RE.fullmatch(param["key"]):
out.append(_out_message_key(idx))
if "description" not in param or not param["description"]:
out.append(_out_message_description(idx))
return out
EVERGREEN_SCRIPT_RE = re.compile(r"\/evergreen\/.*\.sh")
def subprocess_exec_bootstraps_shell(yaml: dict) -> List[LintError]:
"""Require that subprocess.exec functions that consume evergreen scripts correctly bootstrap the prelude."""
def _out_message(context: str, key: str) -> LintError:
return f"{context} is a subprocess.exec command that calls an evergreen shell script without a correctly set environment. You must set 'params.env.{key}' to '${{{key}}}'."
def _out_message_binary(context: str) -> LintError:
return f"{context} is a subprocess.exec command that calls an evergreen shell script through a binary other than bash, which is unsupported."
# we're looking for subprocess exec commands that look like this
#- command: subprocess.exec
# params:
# args:
# - "src/evergreen/do_something.sh"
# if we find one, we want to ensure that env on params is set to correctly
# allow activate_venv to be bootstrapped, and the binary is set to bash
out: List[LintError] = []
for context, command in iterate_commands(yaml):
if "command" in command and command["command"] != "subprocess.exec":
continue
if "params" not in command:
continue
params = command["params"]
if "args" not in params or not EVERGREEN_SCRIPT_RE.search(params["args"][0]):
continue
if "binary" not in params or params["binary"] != "bash":
out.append(_out_message_binary(context))
if "env" in params:
if "workdir" not in params["env"] or params["env"]["workdir"] != "${workdir}":
out.append(_out_message(context, "workdir"))
if "python" not in params["env"] or params["env"]["python"] != "${python}":
out.append(_out_message(context, "python"))
else:
out.append(_out_message(context, "workdir"))
out.append(_out_message(context, "python"))
return out
RULES: Dict[str, LintRule] = {
#"invalid-function-name": invalid_function_name,
# TODO: after SERVER-54315
#"no-keyval-inc": no_keyval_inc,
#"no-working-dir-on-shell": no_working_dir_on_shell,
"shell-exec-explicit-shell": shell_exec_explicit_shell,
# this rule contradicts the above. When you turn it on, delete shell_exec_explicit_shell
#"no-shell-exec": no_shell_exec
#"no-multiline-expansions-update": no_multiline_expansions_update,
"invalid-build-parameter": invalid_build_parameter,
#"subprocess-exec-bootstraps-shell": subprocess_exec_bootstraps_shell
}
# Thoughts on Writing Rules
# - see .helpers for reliable iteration helpers
# - Do not assume a key exists, unless it's been mentioned here
# - Do not allow exceptions to percolate outside of the rule function
# - YAML anchors are not available. Unless you want to write your own yaml
# parser, or fork adrienverge/yamllint, abandon all hope on that idea you have.
# - Anchors are basically copy and paste, so you might see "duplicate" errors
# that originate from the same anchor, but are reported in multiple locations
# Evergreen YAML Root Structure Reference
# Unless otherwise mentioned, the key is optional. You can infer the
# substructure by reading etc/evergreen.yml
# Function blocks: are dicts with the key 'func', which maps to a string,
# the name of the function
# Command blocks: are dicts with the key 'command', which maps to a string,
# the Evergreen command to run
# variables: List[dict]. These can be any valid yaml and it's very difficult
# to infer anything
# functions: Dict[str, Union[dict, List[dict]]]. The key is the name of the
# function, the value is either a dict, or list of dicts, with each dict
# representing a command
# pre, post, and timeout: List[dict] representing commands or functions to
# be run before/after/on timeout condition respectively
# tasks: List[dict], each dict is a task definition, key is always present
# task_groups: List[dict]
# modules: List[dict]
# buildvariants: List[dict], key is always present
# parameters: List[dict]