Files
mongo/buildscripts/ciconfig/evergreen.py
Steve McClure 854e973aaa SERVER-91349: Improve test_burn_in_tests_end2end.py/test_valid_yaml_output failure mode (#23249)
GitOrigin-RevId: e5aa9e36763de196a182bd8c1b218f3d16b18f78
2024-06-11 16:48:31 +00:00

440 lines
16 KiB
Python

"""API to parse and access the configuration present in a evergreen.yml file.
The API also provides methods to access specific fields present in the mongodb/mongo
configuration file.
"""
from __future__ import annotations
import datetime
import os
import re
import shutil
import subprocess
import sys
from typing import Any, Dict, List, Optional, Set
import structlog
import yaml
from buildscripts.util import runcommand
ENTERPRISE_MODULE_NAME = "enterprise"
ASAN_SIGNATURE = "detect_leaks=1"
LOGGER = structlog.get_logger(__name__)
def parse_evergreen_file(path, evergreen_binary="evergreen"):
"""Read an Evergreen file and return EvergreenProjectConfig instance."""
if evergreen_binary:
if not shutil.which(evergreen_binary):
# On windows in python3.8 there was an update to no longer use HOME in os.path.expanduser
# However, cygwin is weird and has HOME but not USERPROFILE
# So we just check if HOME is set and USERPROFILE is not
# Then we just set USERPROFILE and unset it after
# Bug is here: https://bugs.python.org/issue36264
prev_environ = os.environ.copy()
if sys.platform in ("win32", "cygwin"):
LOGGER.info(f"Previous os.environ={os.environ} before updating 'USERPROFILE'")
if "HOME" in os.environ:
os.environ["USERPROFILE"] = os.environ["HOME"]
else:
LOGGER.warn(
"'HOME' enviorment variable unset. This will likely cause us to be unable to find evergreen binary."
)
default_evergreen_location = os.path.expanduser(os.path.join("~", "evergreen"))
# Restore enviorment if it was modified above on windows
os.environ.clear()
os.environ.update(prev_environ)
if os.path.exists(default_evergreen_location):
evergreen_binary = default_evergreen_location
elif os.path.exists(f"{default_evergreen_location}.exe"):
evergreen_binary = f"{default_evergreen_location}.exe"
else:
raise EnvironmentError(
f"Executable {evergreen_binary} (default location: {default_evergreen_location}) does not exist or is not in the PATH. PATH={os.environ.get('PATH')}"
)
else:
evergreen_binary = shutil.which(evergreen_binary)
# Call 'evergreen evaluate path' to pre-process the project configuration file.
cmd = [evergreen_binary, "evaluate", path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode:
raise RuntimeError(
"Unable to evaluate {}.\nSTDOUT:{}\nSTDERR:{}".format(
path, result.stdout, result.stderr
)
)
config = yaml.safe_load(result.stdout)
else:
with open(path, "r") as fstream:
config = yaml.safe_load(fstream)
return EvergreenProjectConfig(config)
class EvergreenProjectConfig(object):
"""Represent an Evergreen project configuration file."""
def __init__(self, conf):
"""Initialize the EvergreenProjectConfig from a YML dictionary."""
self._conf = conf
self.tasks = [Task(task_dict) for task_dict in self._conf["tasks"]]
self._tasks_by_name = {task.name: task for task in self.tasks}
self.task_groups = [
TaskGroup(task_group_dict) for task_group_dict in self._conf.get("task_groups", [])
]
self._task_groups_by_name = {task_group.name: task_group for task_group in self.task_groups}
self.variants = [
Variant(variant_dict, self._tasks_by_name, self._task_groups_by_name)
for variant_dict in self._conf["buildvariants"]
]
self._variants_by_name = {variant.name: variant for variant in self.variants}
self.distro_names = set()
for variant in self.variants:
self.distro_names.update(variant.distro_names)
self.functions = self._conf["functions"]
@property
def task_names(self) -> List[str]:
"""Get the list of task names."""
return list(self._tasks_by_name.keys())
def get_task(self, task_name: str) -> Task:
"""Return the task with the given name as a Task instance."""
return self._tasks_by_name.get(task_name)
@property
def task_group_names(self):
"""Get the list of task_group names."""
return list(self._task_groups_by_name.keys())
def get_task_group(self, task_group_name):
"""Return the task_group with the given name as a Task instance."""
return self._task_groups_by_name.get(task_group_name)
@property
def variant_names(self):
"""Get the list of build variant names."""
return list(self._variants_by_name.keys())
def get_variant(self, variant_name: str) -> Variant:
"""Return the variant with the given name as a Variant instance."""
return self._variants_by_name.get(variant_name)
def get_required_variants(self) -> Set[Variant]:
"""Get the list of required build variants."""
return {variant for variant in self.variants if variant.is_required_variant()}
def get_task_names_by_tag(self, tag):
"""Return the list of tasks that have the given tag."""
return list(task.name for task in self.tasks if tag in task.tags)
class Task(object):
"""Represent a task configuration as found in an Evergreen project configuration file."""
def __init__(self, conf_dict):
"""Initialize a Task from a dictionary containing its configuration."""
self.raw = conf_dict
# Lazy parse task tags.
self._tags = None
@property
def name(self):
"""Get the task name."""
return self.raw["name"]
@property
def depends_on(self):
"""Get the list of task names this task depends on."""
return self.raw.get("depends_on", [])
def find_func_command(self, func_command):
"""Return the 'func_command' if found, or None."""
for command in self.raw.get("commands", []):
if command.get("func") == func_command:
return command
return None
@property
def generate_resmoke_tasks_command(self) -> Optional[Dict[str, Any]]:
"""Return the 'generate resmoke tasks' command if found, or None."""
return self.find_func_command("generate resmoke tasks")
@property
def is_generate_resmoke_task(self) -> bool:
"""Return True if 'generate resmoke tasks' command is found."""
return self.generate_resmoke_tasks_command is not None
@property
def run_tests_command(self) -> Optional[Dict[str, Any]]:
"""Return the 'run tests' command if found, or None."""
return self.find_func_command("run tests")
@property
def is_run_tests_task(self) -> bool:
"""Return True if 'run_tests' command is found."""
return self.run_tests_command is not None
@property
def initialize_multiversion_tasks_command(self) -> Optional[Dict[str, Any]]:
"""Return the 'initialize multiversion tasks' command if found, or None."""
return self.find_func_command("initialize multiversion tasks")
@property
def is_initialize_multiversion_tasks_task(self) -> bool:
"""Return True if 'initialize multiversion tasks' command is found."""
return self.initialize_multiversion_tasks_command is not None
@property
def generated_task_name(self) -> str:
"""
Get basename of the tasks generated by this _gen task.
:return: Basename of the generated tasks.
"""
if not self.is_generate_resmoke_task:
raise TypeError("Only _gen tasks can have generated task names")
return self.name[:-4]
def get_resmoke_command_vars(self) -> Dict[str, Any]:
"""Get the vars for either 'generate resmoke tasks' or 'run tests', both eventually call resmoke.py."""
if self.is_run_tests_task:
return self.run_tests_command.get("vars", {})
if self.is_generate_resmoke_task:
return self.generate_resmoke_tasks_command.get("vars", {})
return {}
def get_suite_names(self) -> List[str]:
"""Get the names of the resmoke.py suites from the task definition."""
command_vars = self.get_resmoke_command_vars()
if self.is_run_tests_task:
return [command_vars.get("suite", self.name)]
if self.is_generate_resmoke_task and not self.is_initialize_multiversion_tasks_task:
return [command_vars.get("suite", self.generated_task_name)]
if self.is_initialize_multiversion_tasks_task:
return [
suite for suite in self.initialize_multiversion_tasks_command.get("vars", {}).keys()
]
raise ValueError(f"{self.name} task does not run a resmoke.py test suite")
@property
def suite_to_resmoke_args_map(self) -> Dict[str, str]:
"""Get the resmoke.py arguments from the task definition."""
output = {}
for suite_name in self.get_suite_names():
resmoke_args = f"--suites={suite_name}"
more_args = self.get_resmoke_command_vars().get("resmoke_args")
if more_args is not None:
resmoke_args = f"{resmoke_args} {more_args}"
output[suite_name] = resmoke_args
return output
@property
def tags(self):
"""Get a set of tags this task has been marked with."""
if self._tags is None:
self._tags = set(self.raw.get("tags", []))
return self._tags
def __str__(self):
return self.name
class TaskGroup(object):
"""Represent a task_group configuration as found in an Evergreen project configuration file."""
def __init__(self, conf_dict):
"""Initialize a TaskGroup from a dictionary containing its configuration."""
self.raw = conf_dict
@property
def name(self):
"""Get the task_group name."""
return self.raw["name"]
@property
def tasks(self):
"""Get the list of task names for task_group."""
return self.raw.get("tasks", [])
def __str__(self):
return self.name
class Variant(object):
"""Build variant configuration as found in an Evergreen project configuration file."""
def __init__(self, conf_dict, task_map, task_group_map):
"""Initialize Variant."""
self.raw = conf_dict
run_on = self.run_on
self.tasks = []
for task in conf_dict["tasks"]:
task_name = task.get("name")
if task_name in task_group_map:
# A task in conf_dict may be a task_group, containing a list of tasks.
for task_in_group in task_group_map.get(task_name).tasks:
self.tasks.append(
VariantTask(task_map.get(task_in_group), task.get("distros", run_on), self)
)
else:
self.tasks.append(
VariantTask(task_map.get(task["name"]), task.get("distros", run_on), self)
)
self.distro_names = set(run_on)
for task in self.tasks:
self.distro_names.update(task.run_on)
def __repr__(self):
"""Create a string version of object for debugging."""
return self.name
@property
def name(self):
"""Get the build variant name."""
return self.raw["name"]
@property
def display_name(self):
"""Get the build variant display name, or None if not found."""
return self.raw.get("display_name")
@property
def batchtime(self):
"""Get the build variant batchtime parameter as datetime.timedelta.
Return None if the batchtime parameter is not found.
"""
batchtime = self.raw.get("batchtime")
return datetime.timedelta(minutes=batchtime) if batchtime is not None else None
@property
def modules(self):
"""Get build variant modules parameter as a list of module names."""
modules = self.raw.get("modules")
return modules if modules is not None else []
def is_enterprise_build(self) -> bool:
"""Determine if the build variant is configured for enterprise builds."""
pattern = r"--enableEnterpriseTests\s*=?\s*off"
return not re.search(pattern, str(self.raw))
@property
def run_on(self):
"""Get build variant run_on parameter as a list of distro names."""
run_on = self.raw.get("run_on")
return run_on if run_on is not None else []
@property
def task_names(self):
"""Get list of task names."""
return [t.name for t in self.tasks]
def is_required_variant(self) -> bool:
"""Return True if the variant is a required variant."""
return self.display_name.startswith("!")
def get_task(self, task_name):
"""Return the task with the given name as an instance of VariantTask.
Return None if this variant does not run the task.
"""
for task in self.tasks:
if task.name == task_name:
return task
return None
def __str__(self):
return self.name
# Expansions
def expansion(self, name):
"""Return the value of the expansion named 'name', or None if not found."""
return self.raw.get("expansions", {}).get(name)
@property
def expansions(self):
"""Get the expansions."""
return self.raw.get("expansions", [])
@property
def test_flags(self):
"""Get the value of the test_flags expansion or None if not found."""
return self.expansion("test_flags")
@property
def num_jobs_available(self):
"""Get the value of the num_jobs_available expansion or None if not found."""
return self.expansion("num_jobs_available")
def is_asan_build(self) -> bool:
"""Determine if this task is an ASAN build."""
san_options = self.expansion("san_options")
if san_options:
return ASAN_SIGNATURE in san_options
return False
@property
def idle_timeout_factor(self) -> Optional[float]:
"""Get the value of idle_timeout_factor expansion or None if not found."""
factor = self.expansion("idle_timeout_factor")
if factor:
return float(factor)
return None
@property
def exec_timeout_factor(self) -> Optional[float]:
"""Get the value of exec_timeout_factor expansion or None if not found."""
factor = self.expansion("exec_timeout_factor")
if factor:
return float(factor)
return None
class VariantTask(Task):
"""Represent a task definition in the context of a build variant."""
def __init__(self, task, run_on, variant):
"""Initialize VariantTask."""
Task.__init__(self, task.raw)
self.run_on = run_on
self.variant = variant
def __repr__(self):
"""Create a string representation of object for debugging."""
return f"{self.variant}: {self.name}"
@property
def combined_suite_to_resmoke_args_map(self) -> Dict[str, str]:
"""Get the combined resmoke arguments.
This results from the concatenation of the task's resmoke_args parameter and the
variant's test_flags parameter.
"""
variant_test_flags = self.variant.test_flags
if variant_test_flags is not None:
output = {}
for suite_name, task_resmoke_args in self.suite_to_resmoke_args_map.items():
output[suite_name] = f"{task_resmoke_args} {variant_test_flags}"
return output
return self.suite_to_resmoke_args_map