summaryrefslogtreecommitdiff
path: root/buildscripts/evergreen_gen_multiversion_tests.py
blob: 976ed5118967c650c9673174084798f5d831c236 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
#!/usr/bin/env python3
"""Generate multiversion tests to run in evergreen in parallel."""

from datetime import datetime, timedelta
import os
import re
import tempfile
from typing import Optional, List
from collections import defaultdict

from subprocess import check_output

import inject
import requests
import click
import structlog
from pydantic import BaseModel

from shrub.v2 import ExistingTask
from evergreen.api import RetryingEvergreenApi, EvergreenApi

from buildscripts.resmokelib.multiversionconstants import (
    LAST_LTS_MONGO_BINARY, LAST_CONTINUOUS_MONGO_BINARY, REQUIRES_FCV_TAG)
from buildscripts.task_generation.evg_config_builder import EvgConfigBuilder
from buildscripts.task_generation.gen_config import GenerationConfiguration
from buildscripts.task_generation.generated_config import GeneratedConfiguration
from buildscripts.task_generation.multiversion_util import MultiversionUtilService
from buildscripts.task_generation.resmoke_proxy import ResmokeProxyConfig
from buildscripts.task_generation.suite_split import SuiteSplitConfig, SuiteSplitParameters
from buildscripts.task_generation.suite_split_strategies import SplitStrategy, FallbackStrategy, \
    greedy_division, round_robin_fallback
from buildscripts.task_generation.task_types.fuzzer_tasks import FuzzerGenTaskParams
from buildscripts.task_generation.task_types.gentask_options import GenTaskOptions
from buildscripts.task_generation.task_types.multiversion_tasks import MultiversionGenTaskParams
from buildscripts.util.cmdutils import enable_logging
from buildscripts.util.fileops import read_yaml_file
import buildscripts.evergreen_generate_resmoke_tasks as generate_resmoke
import buildscripts.evergreen_gen_fuzzer_tests as gen_fuzzer
import buildscripts.ciconfig.tags as _tags

# pylint: disable=len-as-condition
from buildscripts.util.taskname import remove_gen_suffix

LOGGER = structlog.getLogger(__name__)

DEFAULT_CONFIG_DIR = "generated_resmoke_config"
CONFIG_DIR = DEFAULT_CONFIG_DIR
DEFAULT_TEST_SUITE_DIR = os.path.join("buildscripts", "resmokeconfig", "suites")
LOOKBACK_DURATION_DAYS = 14
CONFIG_FILE = generate_resmoke.EVG_CONFIG_FILE
REPL_MIXED_VERSION_CONFIGS = ["new-old-new", "new-new-old", "old-new-new"]
SHARDED_MIXED_VERSION_CONFIGS = ["new-old-old-new"]

BURN_IN_TASK = "burn_in_tests_multiversion"
MULTIVERSION_CONFIG_KEY = "use_in_multiversion"
PASSTHROUGH_TAG = "multiversion_passthrough"
RANDOM_REPLSETS_TAG = "random_multiversion_ds"
BACKPORT_REQUIRED_TAG = "backport_required_multiversion"
EXCLUDE_TAGS = f"{REQUIRES_FCV_TAG},multiversion_incompatible,{BACKPORT_REQUIRED_TAG}"
EXCLUDE_TAGS_FILE = "multiversion_exclude_tags.yml"
GEN_PARENT_TASK = "generator_tasks"
ASAN_SIGNATURE = "detect_leaks=1"

# The directory in which BACKPORTS_REQUIRED_FILE resides.
ETC_DIR = "etc"
BACKPORTS_REQUIRED_FILE = "backports_required_for_multiversion_tests.yml"
BACKPORTS_REQUIRED_BASE_URL = "https://raw.githubusercontent.com/mongodb/mongo"


class EvgExpansions(BaseModel):
    """Evergreen expansions file contents."""

    project: str
    target_resmoke_time: int = 60
    max_sub_suites: int = 5
    max_tests_per_suite: int = 100
    san_options: Optional[str]
    task_name: str
    suite: Optional[str]
    num_files: Optional[int]
    num_tasks: Optional[int]
    resmoke_args: Optional[str]
    npm_command: Optional[str]
    jstestfuzz_vars: Optional[str]
    build_variant: str
    continue_on_failure: Optional[bool]
    resmoke_jobs_max: Optional[int]
    should_shuffle: Optional[bool]
    timeout_secs: Optional[int]
    use_multiversion: Optional[str]
    use_large_distro: Optional[bool]
    large_distro_name: Optional[str]
    revision: str
    build_id: str
    create_misc_suite: bool = True
    is_patch: bool = False
    is_jstestfuzz: bool = False

    @property
    def task(self) -> str:
        """Get the name of the task."""
        return remove_gen_suffix(self.task_name)

    @classmethod
    def from_yaml_file(cls, path: str) -> "EvgExpansions":
        """Read the evergreen expansions from the given file."""
        return cls(**read_yaml_file(path))

    def config_location(self) -> str:
        """Get the location to store the configuration."""
        return f"{self.build_variant}/{self.revision}/generate_tasks/{self.task}_gen-{self.build_id}.tgz"

    def is_asan_build(self) -> bool:
        """Determine if this task is an ASAN build."""
        san_options = self.san_options
        if san_options:
            return ASAN_SIGNATURE in san_options
        return False

    def get_generation_options(self) -> GenTaskOptions:
        """Get options for how tasks should be generated."""
        return GenTaskOptions(
            create_misc_suite=self.create_misc_suite,
            is_patch=self.is_patch,
            generated_config_dir=DEFAULT_CONFIG_DIR,
            use_default_timeouts=False,
        )

    def get_fuzzer_params(self, version_config: str, is_sharded: bool) -> FuzzerGenTaskParams:
        """
        Get parameters to generate fuzzer tasks.

        :param version_config: Version configuration to generate for.
        :param is_sharded: If configuration is for sharded tests.
        :return: Parameters to generate fuzzer tasks.
        """
        name = f"{self.suite}_multiversion_{version_config}"
        add_resmoke_args = get_multiversion_resmoke_args(is_sharded)
        resmoke_args = f"{self.resmoke_args or ''} --mixedBinVersions={version_config} {add_resmoke_args}"

        return FuzzerGenTaskParams(
            num_files=self.num_files,
            num_tasks=self.num_tasks,
            resmoke_args=resmoke_args,
            npm_command=self.npm_command,
            jstestfuzz_vars=self.jstestfuzz_vars,
            task_name=name,
            variant=self.build_variant,
            continue_on_failure=self.continue_on_failure,
            resmoke_jobs_max=self.resmoke_jobs_max,
            should_shuffle=self.should_shuffle,
            timeout_secs=self.timeout_secs,
            use_multiversion=self.use_multiversion,
            suite=self.suite or self.task,
            use_large_distro=self.use_large_distro,
            large_distro_name=self.large_distro_name,
            config_location=self.config_location(),
        )

    def get_split_params(self) -> SuiteSplitParameters:
        """Get the parameters specified to split suites."""
        return SuiteSplitParameters(
            task_name=self.task_name,
            suite_name=self.suite or self.task,
            filename=self.suite or self.task,
            test_file_filter=None,
            build_variant=self.build_variant,
            is_asan=self.is_asan_build(),
        )

    def get_split_config(self, start_date: datetime, end_date: datetime) -> SuiteSplitConfig:
        """
        Get the configuration specifed to split suites.

        :param start_date: Start date for historic results query.
        :param end_date: End date for historic results query.
        :return: Configuration to use for splitting suites.
        """
        return SuiteSplitConfig(
            evg_project=self.project,
            target_resmoke_time=self.target_resmoke_time,
            max_sub_suites=self.max_sub_suites,
            max_tests_per_suite=self.max_tests_per_suite,
            start_date=start_date,
            end_date=end_date,
        )

    def get_generation_params(self, is_sharded: bool) -> MultiversionGenTaskParams:
        """
        Get the parameters to use to generating multiversion tasks.

        :param is_sharded: True if a sharded sutie is being generated.
        :return: Parameters to use for generating multiversion tasks.
        """
        version_config_list = get_version_configs(is_sharded)
        return MultiversionGenTaskParams(
            mixed_version_configs=version_config_list,
            is_sharded=is_sharded,
            resmoke_args=self.resmoke_args,
            parent_task_name=self.task,
            origin_suite=self.suite or self.task,
            use_large_distro=self.use_large_distro,
            large_distro_name=self.large_distro_name,
            config_location=self.config_location(),
        )


def get_version_configs(is_sharded: bool) -> List[str]:
    """Get the version configurations to use."""
    if is_sharded:
        return SHARDED_MIXED_VERSION_CONFIGS
    return REPL_MIXED_VERSION_CONFIGS


def get_multiversion_resmoke_args(is_sharded: bool) -> str:
    """Return resmoke args used to configure a cluster for multiversion testing."""
    if is_sharded:
        return "--numShards=2 --numReplSetNodes=2 "
    return "--numReplSetNodes=3 --linearChain=on "


def get_backports_required_hash_for_shell_version(mongo_shell_path=None):
    """Parse the last-lts shell binary to get the commit hash."""
    shell_version = check_output([mongo_shell_path, "--version"]).decode('utf-8')
    for line in shell_version.splitlines():
        if "gitVersion" in line:
            version_line = line.split(':')[1]
            # We identify the commit hash as the string enclosed by double quotation marks.
            result = re.search(r'"(.*?)"', version_line)
            if result:
                commit_hash = result.group().strip('"')
                if not commit_hash.isalnum():
                    raise ValueError(f"Error parsing commit hash. Expected an "
                                     f"alpha-numeric string but got: {commit_hash}")
                return commit_hash
            else:
                break
    raise ValueError("Could not find a valid commit hash from the last-lts mongo binary.")


def get_last_lts_yaml(commit_hash):
    """Download BACKPORTS_REQUIRED_FILE from the last LTS commit and return the yaml."""
    LOGGER.info(f"Downloading file from commit hash of last-lts branch {commit_hash}")
    response = requests.get(
        f'{BACKPORTS_REQUIRED_BASE_URL}/{commit_hash}/{ETC_DIR}/{BACKPORTS_REQUIRED_FILE}')
    # If the response was successful, no exception will be raised.
    response.raise_for_status()

    last_lts_file = f"{commit_hash}_{BACKPORTS_REQUIRED_FILE}"
    temp_dir = tempfile.mkdtemp()
    with open(os.path.join(temp_dir, last_lts_file), "w") as fileh:
        fileh.write(response.text)

    backports_required_last_lts = read_yaml_file(os.path.join(temp_dir, last_lts_file))
    return backports_required_last_lts


class MultiVersionGenerateOrchestrator:
    """An orchestrator for generating multiversion tasks."""

    @inject.autoparams()
    def __init__(self, evg_api: EvergreenApi, multiversion_util: MultiversionUtilService,
                 gen_task_options: GenTaskOptions) -> None:
        """
        Initialize the orchestrator.

        :param evg_api: Evergreen API client.
        :param multiversion_util: Multiverison utilities service.
        :param gen_task_options: Options to use for generating tasks.
        """
        self.evg_api = evg_api
        self.multiversion_util = multiversion_util
        self.gen_task_options = gen_task_options

    def generate_fuzzer(self, evg_expansions: EvgExpansions) -> GeneratedConfiguration:
        """
        Generate configuration for the fuzzer task specified by the expansions.

        :param evg_expansions: Evergreen expansions describing what to generate.
        :return: Configuration to generate the specified task.
        """
        suite = evg_expansions.suite
        is_sharded = self.multiversion_util.is_suite_sharded(suite)
        version_config_list = get_version_configs(is_sharded)

        builder = EvgConfigBuilder()  # pylint: disable=no-value-for-parameter

        fuzzer_task_set = set()
        for version_config in version_config_list:
            fuzzer_params = evg_expansions.get_fuzzer_params(version_config, is_sharded)
            fuzzer_task = builder.generate_fuzzer(fuzzer_params)
            fuzzer_task_set = fuzzer_task_set.union(fuzzer_task.sub_tasks)

        existing_tasks = {ExistingTask(task) for task in fuzzer_task_set}
        existing_tasks.add({ExistingTask(f"{suite}_multiversion_gen")})
        builder.add_display_task(evg_expansions.task, existing_tasks, evg_expansions.build_variant)
        return builder.build(f"{evg_expansions.task}.json")

    def generate_resmoke_suite(self, evg_expansions: EvgExpansions) -> GeneratedConfiguration:
        """
        Generate configuration for the resmoke task specified by the expansions.

        :param evg_expansions: Evergreen expansions describing what to generate.
        :return: Configuration to generate the specified task.
        """
        suite = evg_expansions.suite or evg_expansions.task
        is_sharded = self.multiversion_util.is_suite_sharded(suite)

        split_params = evg_expansions.get_split_params()
        gen_params = evg_expansions.get_generation_params(is_sharded)

        builder = EvgConfigBuilder()  # pylint: disable=no-value-for-parameter
        builder.add_multiversion_suite(split_params, gen_params)
        builder.add_display_task(GEN_PARENT_TASK, {f"{split_params.task_name}"},
                                 evg_expansions.build_variant)
        return builder.build(f"{evg_expansions.task}.json")

    def generate(self, evg_expansions: EvgExpansions) -> None:
        """
        Generate configuration for the specified task and save it to disk.

        :param evg_expansions: Evergreen expansions describing what to generate.
        """
        if evg_expansions.is_jstestfuzz:
            generated_config = self.generate_fuzzer(evg_expansions)
        else:
            generated_config = self.generate_resmoke_suite(evg_expansions)
        generated_config.write_all_to_dir(DEFAULT_CONFIG_DIR)


@click.group()
def main():
    """Serve as an entry point for the 'run' and 'generate-exclude-tags' commands."""
    pass


@main.command("run")
@click.option("--expansion-file", type=str, required=True,
              help="Location of expansions file generated by evergreen.")
@click.option("--evergreen-config", type=str, default=CONFIG_FILE,
              help="Location of evergreen configuration file.")
def run_generate_tasks(expansion_file: str, evergreen_config: Optional[str] = None):
    """
    Create a configuration for generate tasks to create sub suites for the specified resmoke suite.

    Tests using ReplicaSetFixture will be generated to use 3 nodes and linear_chain=True.
    Tests using ShardedClusterFixture will be generated to use 2 shards with 2 nodes each.
    The different binary version configurations tested are stored in REPL_MIXED_VERSION_CONFIGS
    and SHARDED_MIXED_VERSION_CONFIGS.

    The `--expansion-file` should contain all the configuration needed to generate the tasks.
    \f
    :param expansion_file: Configuration file.
    :param evergreen_config: Evergreen configuration file.
    """
    enable_logging(False)

    end_date = datetime.utcnow().replace(microsecond=0)
    start_date = end_date - timedelta(days=LOOKBACK_DURATION_DAYS)

    evg_expansions = EvgExpansions.from_yaml_file(expansion_file)

    def dependencies(binder: inject.Binder) -> None:
        binder.bind(SuiteSplitConfig, evg_expansions.get_split_config(start_date, end_date))
        binder.bind(SplitStrategy, greedy_division)
        binder.bind(FallbackStrategy, round_robin_fallback)
        binder.bind(GenTaskOptions, evg_expansions.get_generation_options())
        binder.bind(EvergreenApi, RetryingEvergreenApi.get_api(config_file=evergreen_config))
        binder.bind(GenerationConfiguration,
                    GenerationConfiguration.from_yaml_file(gen_fuzzer.GENERATE_CONFIG_FILE))
        binder.bind(ResmokeProxyConfig,
                    ResmokeProxyConfig(resmoke_suite_dir=DEFAULT_TEST_SUITE_DIR))

    inject.configure(dependencies)

    generate_orchestrator = MultiVersionGenerateOrchestrator()  # pylint: disable=no-value-for-parameter
    generate_orchestrator.generate(evg_expansions)


@main.command("generate-exclude-tags")
@click.option("--output", type=str, default=os.path.join(CONFIG_DIR, EXCLUDE_TAGS_FILE),
              show_default=True, help="Where to output the generated tags.")
def generate_exclude_yaml(output: str) -> None:
    # pylint: disable=too-many-locals
    """
    Create a tag file associating multiversion tests to tags for exclusion.

    Compares the BACKPORTS_REQUIRED_FILE on the current branch with the same file on the
    last-lts branch to determine which tests should be denylisted.
    """

    enable_logging(False)

    location, _ = os.path.split(os.path.abspath(output))
    if not os.path.isdir(location):
        LOGGER.info(f"Cannot write to {output}. Not generating tag file.")
        return

    backports_required_latest = read_yaml_file(os.path.join(ETC_DIR, BACKPORTS_REQUIRED_FILE))

    # Get the state of the backports_required_for_multiversion_tests.yml file for the last-lts
    # binary we are running tests against. We do this by using the commit hash from the last-lts
    # mongo shell executable.
    last_lts_commit_hash = get_backports_required_hash_for_shell_version(
        mongo_shell_path=LAST_LTS_MONGO_BINARY)

    # Get the yaml contents from the last-lts commit.
    backports_required_last_lts = get_last_lts_yaml(last_lts_commit_hash)

    def diff(list1, list2):
        return [elem for elem in (list1 or []) if elem not in (list2 or [])]

    suites_latest = backports_required_latest["suites"] or {}
    # Check if the changed syntax for etc/backports_required_multiversion.yml has been backported.
    # This variable and all branches where it's not set can be deleted after backporting the change.
    change_backported = "all" in backports_required_last_lts.keys()
    if change_backported:
        always_exclude = diff(backports_required_latest["all"], backports_required_last_lts["all"])
        suites_last_lts: defaultdict = defaultdict(list, backports_required_last_lts["suites"])
    else:
        always_exclude = backports_required_latest["all"] or []
        suites_last_lts = defaultdict(list, backports_required_last_lts)
        for suite in suites_latest.keys():
            for elem in suites_last_lts[suite] or []:
                if elem in always_exclude:
                    always_exclude.remove(elem)

    tags = _tags.TagsConfig()

    # Tag tests that are excluded from every suite.
    for elem in always_exclude:
        tags.add_tag("js_test", elem["test_file"], BACKPORT_REQUIRED_TAG)

    # Tag tests that are excluded on a suite-by-suite basis.
    for suite in suites_latest.keys():
        test_set = set()
        for elem in diff(suites_latest[suite], suites_last_lts[suite]):
            test_set.add(elem["test_file"])
        for test in test_set:
            tags.add_tag("js_test", test, f"{suite}_{BACKPORT_REQUIRED_TAG}")

    LOGGER.info(f"Writing exclude tags to {output}.")
    tags.write_file(filename=output,
                    preamble="Tag file that specifies exclusions from multiversion suites.")


if __name__ == '__main__':
    main()  # pylint: disable=no-value-for-parameter