From 481f56d7bb2a2e901b41d54ff0e27204fe8a8cad Mon Sep 17 00:00:00 2001 From: Victor Holanda Rusu Date: Wed, 4 Nov 2020 09:52:24 +0100 Subject: [PATCH 01/11] Initial implementation of ci-tags --- reframe/frontend/cli.py | 241 +++++++++++++++++++++++++++++++--------- 1 file changed, 188 insertions(+), 53 deletions(-) diff --git a/reframe/frontend/cli.py b/reframe/frontend/cli.py index 2b6f492058..c59fcea08c 100644 --- a/reframe/frontend/cli.py +++ b/reframe/frontend/cli.py @@ -109,6 +109,148 @@ def generate_report_filename(filepatt): return filepatt.format(sessionid=new_id) +def locate_and_load_tests(options, loader, ci_tag=[]): + # Locate and load checks + try: + checks_found = loader.load_all() + except OSError as e: + raise errors.ReframeError from e + + # Filter checks by name + checks_matched = checks_found + if options.exclude_names: + for name in options.exclude_names: + checks_matched = filter(filters.have_not_name(name), + checks_matched) + + if options.names: + checks_matched = filter(filters.have_name('|'.join(options.names)), + checks_matched) + + # Filter checks by tags + for tag in options.tags: + checks_matched = filter(filters.have_tag(tag), checks_matched) + + # Filter checks by ci tags + for tag in ci_tag: + checks_matched = filter(filters.have_tag(tag), checks_matched) + + # Filter checks by prgenv + if not options.skip_prgenv_check: + for prgenv in options.prgenv: + checks_matched = filter(filters.have_prgenv(prgenv), + checks_matched) + + # Filter checks by system + if not options.skip_system_check: + checks_matched = filter( + filters.have_partition(rt.system.partitions), checks_matched) + + if options.gpu_only: + checks_matched = filter(filters.have_gpu_only(), checks_matched) + elif options.cpu_only: + checks_matched = filter(filters.have_cpu_only(), checks_matched) + + # Determine the allowed programming environments + allowed_environs = {e.name + for env_patt in options.prgenv + for p in rt.system.partitions + for e in p.environs if re.match(env_patt, e.name)} + + # Generate the test cases, validate dependencies and sort them + checks_matched = list(checks_matched) + + # Disable hooks + for c in checks_matched: + for h in options.hooks: + type(c).disable_hook(h) + + testcases = generate_testcases(checks_matched, + options.skip_system_check, + options.skip_prgenv_check, + allowed_environs) + testgraph = dependency.build_deps(testcases) + # dependency.validate_deps(testgraph) + # testcases = dependency.toposort(testgraph) + + return testgraph + +# TODO place this function in a proper module +# TODO define mechanism on how to propagate command line options, currently +# we are passing site_config, but that's clearly a bad design +# read below the options we need to propagate or make available to each +# ci invokation +# An alternative design would be to have environment variables exported +# during the trigger phase of reframe that would influence the tests +# individually +def generate_ci_pipeline_file(ci_pipeline_file, testgraph, site_config): + import reframe.utility as util + + def _hash_test_names(test): + # Need to hash the test names to make them yaml compatible + cname = test.check.name + pname = test.partition.fullname + ename = test.environ.name + # still need to convert characters because partitions + # can have the : char, for example + return util.toalphanum(cname + "_" + pname + "_" + ename) + + tests, graphdepth = dependencies.toposortdepth(testgraph) + # TODO Should be using py-yaml lib here + with open(ci_pipeline_file, 'w') as pipeline_file: + pipeline_file.write('stages:\n') + for i in range(graphdepth+1): + pipeline_file.write(f' - rfm-stage-{i}\n') + pipeline_file.write('\n') + for c, k in tests.items(): + pipeline_file.write(f'{_hash_test_names(c)}:\n') + depth = k['depth'] + pipeline_file.write(f' stage: rfm-stage-{depth}\n') + pipeline_file.write(f' script:\n') + # TODO there are some options that need to be revisited + # - about the test folder: reframe's -c option + # - about the configuration file to use: reframe's -C option + # - about the artifacts artifacts: {paths: [jobs_scratch_dir], when: always} + + # TODO there are some missing options: + # - about other potentially important options: + # + --keep-stage-files + # + --ignore-check-conflicts + # + -p + # + --gpu-only + # + --cpu-only + # + -A + # + -P + # + --reservation + # + --skip-performance-check + # + --nodelist + # + --exclude-nodes + # + --skip-system-check + # + --skip-prgenv-check + # + --mode + # + --max-retries + # + -M + # + -m + # + --module-mappings + # + --failure-stats + # + --performance-report + load_path='-c '.join(site_config.get('general/0/check_search_path')) + recurse='-R' if site_config.get('general/0/check_search_recursive') else '' + ignore_conflicts='--ignore-check-conflicts' if site_config.get('general/0/ignore_check_conflicts') else '' + pipeline_file.write(f' - {reframe.INSTALL_PREFIX}/bin/reframe -C {site_config.filename} -c {load_path} {recurse} {ignore_conflicts} --prefix rfm_tests_stage_dir -n {c.check.name} -r\n') + + if k['depends']: + pipeline_file.write(f' needs:\n') + for d in k['depends']: + pipeline_file.write(f' - {_hash_test_names(d)}\n') + else: + pipeline_file.write(f' needs: []\n') + + pipeline_file.write(f' artifacts:\n') + pipeline_file.write(f' paths: \n') + pipeline_file.write(f' - rfm_tests_stage_dir \n') + pipeline_file.write('\n') + def main(): # Setup command line options argparser = argparse.ArgumentParser() @@ -297,6 +439,19 @@ def main(): '--disable-hook', action='append', metavar='NAME', dest='hooks', default=[], help='Disable a pipeline hook for this run' ) + run_options.add_argument( + '--ci-generate-pipeline', action='store', metavar='FILE', + help="Store ci pipeline in yaml FILE", + envvar='RFM_CI_PIPELINE_FILE', + configvar='general/ci_pipeline_file' + ) + run_options.add_argument( + '--ci-pipeline-tags', action='append', metavar='OPT', default=[], + help="Select the ci pipeline stages from tags FILE", + envvar='RFM_CI_PIPELINE_TAGS', + configvar='general/ci_pipeline_tags' + ) + env_options.add_argument( '-M', '--map-module', action='append', metavar='MAPPING', dest='module_mappings', default=[], @@ -572,72 +727,52 @@ def print_infoline(param, value): print_infoline('stage directory', repr(session_info['prefix_stage'])) print_infoline('output directory', repr(session_info['prefix_output'])) printer.info('') - try: - # Locate and load checks - try: - checks_found = loader.load_all() - except OSError as e: - raise errors.ReframeError from e - - # Filter checks by name - checks_matched = checks_found - if options.exclude_names: - for name in options.exclude_names: - checks_matched = filter(filters.have_not_name(name), - checks_matched) - if options.names: - checks_matched = filter(filters.have_name('|'.join(options.names)), - checks_matched) + if site_config.get('general/0/ci_pipeline_file'): + ci_pipeline_file = site_config.get('general/0/ci_pipeline_file') - # Filter checks by tags - for tag in options.tags: - checks_matched = filter(filters.have_tag(tag), checks_matched) + ci_tags=site_config.get('general/0/ci_pipeline_tags') - # Filter checks by prgenv - if not options.skip_prgenv_check: - for prgenv in options.prgenv: - checks_matched = filter(filters.have_prgenv(prgenv), - checks_matched) - - # Filter checks by system - if not options.skip_system_check: - checks_matched = filter( - filters.have_partition(rt.system.partitions), checks_matched) + # if one is defined but one is not defined + if ci_pipeline_file or ci_tags and not (ci_pipeline_file and ci_tags): + printer.error("options `--ci-pipeline-file' and `--ci-tags' " + "must be used together") + sys.exit(1) + try: # Filter checks further if options.gpu_only and options.cpu_only: printer.error("options `--gpu-only' and `--cpu-only' " "are mutually exclusive") sys.exit(1) - if options.gpu_only: - checks_matched = filter(filters.have_gpu_only(), checks_matched) - elif options.cpu_only: - checks_matched = filter(filters.have_cpu_only(), checks_matched) - - # Determine the allowed programming environments - allowed_environs = {e.name - for env_patt in options.prgenv - for p in rt.system.partitions - for e in p.environs if re.match(env_patt, e.name)} - - # Generate the test cases, validate dependencies and sort them - checks_matched = list(checks_matched) - - # Disable hooks - for c in checks_matched: - for h in options.hooks: - type(c).disable_hook(h) - - testcases = generate_testcases(checks_matched, - options.skip_system_check, - options.skip_prgenv_check, - allowed_environs) - testgraph = dependency.build_deps(testcases) + if ci_tags: + for tag in ci_tags: + testgraph = locate_and_load_tests(options, loader, ci_tag=tag) + dependency.validate_deps(testgraph) + generate_ci_pipeline_file(ci_pipeline_file, testgraph, site_config) + + sys.exit(0) + + testgraph = locate_and_load_tests(options, loader, ci_tag=tag) dependency.validate_deps(testgraph) testcases = dependency.toposort(testgraph) + # # TODO: proper exit reframe in case we have ci-generate + # if site_config.get('general/0/ci_pipeline_file'): + # ci_pipeline_file = site_config.get('general/0/ci_pipeline_file') + # # TODO filter based on tags and generate set of stages based on the tags + # generate_ci_pipeline_file(ci_pipeline_file, testgraph, site_config) + # sys.exit(0) + + + + + + + + + # Manipulate ReFrame's environment if site_config.get('general/0/purge_environment'): rt.modules_system.unload_all() From 2c8677f3e4f30eb8143d4ee1d4d7b1fa39fd6cb5 Mon Sep 17 00:00:00 2001 From: Victor Holanda Rusu Date: Thu, 3 Dec 2020 22:01:14 +0100 Subject: [PATCH 02/11] Add ci-generate-pipeline cli option --- reframe/frontend/ci.py | 67 ++++++++++++++++++++++++++ reframe/frontend/cli.py | 18 +++++++ reframe/frontend/dependencies.py | 27 +++++++++++ reframe/frontend/executors/__init__.py | 3 ++ 4 files changed, 115 insertions(+) create mode 100644 reframe/frontend/ci.py diff --git a/reframe/frontend/ci.py b/reframe/frontend/ci.py new file mode 100644 index 0000000000..d922bcfc74 --- /dev/null +++ b/reframe/frontend/ci.py @@ -0,0 +1,67 @@ +# Copyright 2016-2020 Swiss National Supercomputing Centre (CSCS/ETH Zurich) +# ReFrame Project Developers. See the top-level LICENSE file for details. +# +# SPDX-License-Identifier: BSD-3-Clause + +import inspect +import yaml + +import reframe +import reframe.core.exceptions as errors +import reframe.core.runtime as runtime + + +def _generate_gitlab_pipeline(testcases): + rt = runtime.runtime() + + rfm_exec = f'{reframe.INSTALL_PREFIX}/bin/reframe ' + rfm_prefix = '--prefix rfm_testcases_stage_dir' + load_path='-c '.join(rt.site_config.get('general/0/check_search_path')) + recurse='-R' if rt.site_config.get('general/0/check_search_recursive') else '' + report_file = 'rfm_report.json' + + # getting the max level + max_level = 0 + for test in testcases: + max_level = max(max_level, test.level) + + pipeline_info = {} + for tc in testcases: + # when restoring tests in stages we need to be able to load them + restore_opt = f'--restore-session={report_file}' if tc.level != max_level else '' + test_file = inspect.getfile(type(tc.check)) + pipeline_info[f'{tc.check.name}'] = { + 'stage' : f'rfm-stage-{max_level - tc.level}', + 'script' : [ + f'{rfm_exec} {rfm_prefix} -C {rt.site_config.filename} -c {load_path} {recurse} -n {tc.check.name} -r --report-file {report_file} {restore_opt}' + ], + 'artifacts' : { + 'paths' : 'rfm_testcases_stage_dir' + }, + 'needs' : [t.check.name for t in tc.deps] + } + max_level = max(max_level, tc.level) + + stages = { + 'stages': [f'rfm-stage-{m}' for m in range(max_level+1)] + } + + return stages, pipeline_info + + +def generate_ci_file(filename, stages, pipeline_info): + with open(filename, 'w') as pipeline_file: + for entry in yaml.safe_dump(stages, indent=2).split('\n'): + pipeline_file.write(f'{entry}\n') + + for entry in yaml.safe_dump(pipeline_info, indent=2).split('\n'): + pipeline_file.write(f'{entry}\n') + + +def generate_ci_pipeline(filename, testcases, backend='gitlab'): + if backend != 'gitlab': + raise errors.ReframeError(f'unknown CI backend {backend!r}') + + stages, pipeline_info = _generate_gitlab_pipeline(testcases) + + generate_ci_file(filename, stages, pipeline_info) diff --git a/reframe/frontend/cli.py b/reframe/frontend/cli.py index 2a6716cbbf..d47d8d9f8c 100644 --- a/reframe/frontend/cli.py +++ b/reframe/frontend/cli.py @@ -30,6 +30,7 @@ AsynchronousExecutionPolicy) from reframe.frontend.loader import RegressionCheckLoader from reframe.frontend.printer import PrettyPrinter +from reframe.frontend.ci import generate_ci_pipeline def format_check(check, check_deps, detailed=False): @@ -334,6 +335,14 @@ def main(): '--disable-hook', action='append', metavar='NAME', dest='hooks', default=[], help='Disable a pipeline hook for this run' ) + run_options.add_argument( + '--ci-generate-pipeline', action='store', metavar='FILE', + help="Store ci pipeline in yaml FILE", + envvar='RFM_CI_PIPELINE_FILE', + configvar='general/ci_pipeline_file' + ) + + # Environment options env_options.add_argument( '-M', '--map-module', action='append', metavar='MAPPING', dest='module_mappings', default=[], @@ -704,6 +713,15 @@ def print_infoline(param, value): testcases = dependencies.toposort(testgraph) printer.verbose(f'Final number of test cases: {len(testcases)}') + # testcases2 = dependencies.toposortdepth(testgraph) + + # victor + if site_config.get('general/0/ci_pipeline_file'): + ci_pipeline_file = site_config.get('general/0/ci_pipeline_file') + generate_ci_pipeline(ci_pipeline_file, testcases) + # generate_ci_pipeline(ci_pipeline_file, testcases, site_config) + sys.exit(0) + # Disable hooks for tc in testcases: for h in options.hooks: diff --git a/reframe/frontend/dependencies.py b/reframe/frontend/dependencies.py index dea4b0dde7..63c37c8cd4 100644 --- a/reframe/frontend/dependencies.py +++ b/reframe/frontend/dependencies.py @@ -15,6 +15,31 @@ from reframe.core.exceptions import DependencyError from reframe.core.logging import getlogger +def _dfs(graph): + visited = util.OrderedSet() + + def visit(node, path, level): + # We assume an acyclic graph + assert node not in path + + path.add(node) + + # Do a DFS visit of all the adjacent nodes + depth = 0 + for adj in graph[node]: + if adj not in visited: + visit(adj, path, level + 1) + else: + depth = max(depth, visited[adj].level + 1) + + path.pop() + node.level = max(level, depth) + + for node in graph.keys(): + if node not in visited: + visit(node, util.OrderedSet(), 0) + + return visited def build_deps(cases, default_cases=None): '''Build dependency graph from test cases. @@ -90,6 +115,8 @@ def resolve_dep(src, dst): for v in adjacent: v.in_degree += 1 + _dfs(graph) + return graph, skipped_cases diff --git a/reframe/frontend/executors/__init__.py b/reframe/frontend/executors/__init__.py index dd5b278cb7..be24baf5e2 100644 --- a/reframe/frontend/executors/__init__.py +++ b/reframe/frontend/executors/__init__.py @@ -39,6 +39,9 @@ def __init__(self, check, partition, environ): # Incoming dependencies self.in_degree = 0 + # Level in the dependency chain + self.level = 0 + def __iter__(self): # Allow unpacking a test case with a single liner: # c, p, e = case From 0551ff807e88f6bd491fb7f88bf1a015847b0762 Mon Sep 17 00:00:00 2001 From: Victor Holanda Rusu Date: Thu, 3 Dec 2020 22:11:24 +0100 Subject: [PATCH 03/11] Add PyYAML to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index e85dd3416f..7cf22130a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ pytest-parallel==0.1.0 coverage==5.3 setuptools==50.3.0 wcwidth==0.2.5 +PyYAML==5.3.1 #+pygelf%pygelf==0.3.6 From 06f33098d4e8088f2906ee2827334080d570bb46 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Tue, 2 Feb 2021 18:35:58 +0100 Subject: [PATCH 04/11] Fine tune implementation. --- reframe/frontend/ci.py | 82 +++++++++++++++----------------- reframe/frontend/cli.py | 15 ++---- reframe/frontend/dependencies.py | 44 +++++------------ unittests/test_dependencies.py | 21 ++++++++ 4 files changed, 77 insertions(+), 85 deletions(-) diff --git a/reframe/frontend/ci.py b/reframe/frontend/ci.py index d922bcfc74..dacdc7de52 100644 --- a/reframe/frontend/ci.py +++ b/reframe/frontend/ci.py @@ -11,57 +11,51 @@ import reframe.core.runtime as runtime -def _generate_gitlab_pipeline(testcases): - rt = runtime.runtime() - - rfm_exec = f'{reframe.INSTALL_PREFIX}/bin/reframe ' - rfm_prefix = '--prefix rfm_testcases_stage_dir' - load_path='-c '.join(rt.site_config.get('general/0/check_search_path')) - recurse='-R' if rt.site_config.get('general/0/check_search_recursive') else '' - report_file = 'rfm_report.json' - - # getting the max level - max_level = 0 - for test in testcases: - max_level = max(max_level, test.level) - - pipeline_info = {} +def _emit_gitlab_pipeline(testcases): + config = runtime.runtime().site_config + + # Collect the necessary ReFrame invariants + program = f'{reframe.INSTALL_PREFIX}/bin/reframe' + prefix = 'rfm-stage/${CI_COMMIT_SHORT_SHA}' + checkpath = config.get('general/0/check_search_path') + recurse = config.get('general/0/check_search_recursive') + report = 'rfm_report.json' + + def rfm_command(testcase): + if config.filename != '': + config_opt = f'-C {config.filename}' + else: + config_opt = '' + + return ' '.join([ + program, + f'--prefix={prefix}', config_opt, + f'{"-c ".join(checkpath)}', '-R' if recurse else '', + f'--report-file={report}', + f'--restore-session={report}' if testcase.level else '', + '-n', testcase.check.name, '-r' + ]) + + max_level = 0 # We need the maximum level to generate the stages section + json = {'stages': []} for tc in testcases: - # when restoring tests in stages we need to be able to load them - restore_opt = f'--restore-session={report_file}' if tc.level != max_level else '' - test_file = inspect.getfile(type(tc.check)) - pipeline_info[f'{tc.check.name}'] = { - 'stage' : f'rfm-stage-{max_level - tc.level}', - 'script' : [ - f'{rfm_exec} {rfm_prefix} -C {rt.site_config.filename} -c {load_path} {recurse} -n {tc.check.name} -r --report-file {report_file} {restore_opt}' - ], - 'artifacts' : { - 'paths' : 'rfm_testcases_stage_dir' + json[f'{tc.check.name}'] = { + 'stage': f'rfm-stage-{tc.level}', + 'script': [rfm_command(tc)], + 'artifacts': { + 'paths': prefix }, - 'needs' : [t.check.name for t in tc.deps] + 'needs': [t.check.name for t in tc.deps] } max_level = max(max_level, tc.level) - stages = { - 'stages': [f'rfm-stage-{m}' for m in range(max_level+1)] - } - - return stages, pipeline_info + json['stages'] = [f'rfm-stage-{m}' for m in range(max_level+1)] + return json -def generate_ci_file(filename, stages, pipeline_info): - with open(filename, 'w') as pipeline_file: - for entry in yaml.safe_dump(stages, indent=2).split('\n'): - pipeline_file.write(f'{entry}\n') - - for entry in yaml.safe_dump(pipeline_info, indent=2).split('\n'): - pipeline_file.write(f'{entry}\n') - - -def generate_ci_pipeline(filename, testcases, backend='gitlab'): +def emit_pipeline(fp, testcases, backend='gitlab'): if backend != 'gitlab': raise errors.ReframeError(f'unknown CI backend {backend!r}') - stages, pipeline_info = _generate_gitlab_pipeline(testcases) - - generate_ci_file(filename, stages, pipeline_info) + yaml.dump(_emit_gitlab_pipeline(testcases), stream=fp, + indent=2, sort_keys=False) diff --git a/reframe/frontend/cli.py b/reframe/frontend/cli.py index 9d202c4add..dc914fe62d 100644 --- a/reframe/frontend/cli.py +++ b/reframe/frontend/cli.py @@ -21,6 +21,7 @@ import reframe.core.runtime as runtime import reframe.core.warnings as warnings import reframe.frontend.argparse as argparse +import reframe.frontend.ci as ci import reframe.frontend.dependencies as dependencies import reframe.frontend.filters as filters import reframe.frontend.runreport as runreport @@ -28,7 +29,6 @@ import reframe.utility.osext as osext -from reframe.frontend.ci import generate_ci_pipeline from reframe.frontend.printer import PrettyPrinter from reframe.frontend.loader import RegressionCheckLoader from reframe.frontend.executors.policies import (SerialExecutionPolicy, @@ -336,10 +336,8 @@ def main(): default=[], help='Disable a pipeline hook for this run' ) run_options.add_argument( - '--ci-generate-pipeline', action='store', metavar='FILE', + '--ci-generate', action='store', metavar='FILE', help="Store ci pipeline in yaml FILE", - envvar='RFM_CI_PIPELINE_FILE', - configvar='general/ci_pipeline_file' ) # Environment options @@ -795,13 +793,10 @@ def _case_failed(t): ) printer.verbose(f'Final number of test cases: {len(testcases)}') - # testcases2 = dependencies.toposortdepth(testgraph) + if options.ci_generate: + with open(options.ci_generate, 'wt') as fp: + ci.emit_pipeline(fp, testcases) - # victor - if site_config.get('general/0/ci_pipeline_file'): - ci_pipeline_file = site_config.get('general/0/ci_pipeline_file') - generate_ci_pipeline(ci_pipeline_file, testcases) - # generate_ci_pipeline(ci_pipeline_file, testcases, site_config) sys.exit(0) # Disable hooks diff --git a/reframe/frontend/dependencies.py b/reframe/frontend/dependencies.py index 564d63575a..571a8b126d 100644 --- a/reframe/frontend/dependencies.py +++ b/reframe/frontend/dependencies.py @@ -15,31 +15,6 @@ from reframe.core.exceptions import DependencyError from reframe.core.logging import getlogger -def _dfs(graph): - visited = util.OrderedSet() - - def visit(node, path, level): - # We assume an acyclic graph - assert node not in path - - path.add(node) - - # Do a DFS visit of all the adjacent nodes - depth = 0 - for adj in graph[node]: - if adj not in visited: - visit(adj, path, level + 1) - else: - depth = max(depth, visited[adj].level + 1) - - path.pop() - node.level = max(level, depth) - - for node in graph.keys(): - if node not in visited: - visit(node, util.OrderedSet(), 0) - - return visited def build_deps(cases, default_cases=None): '''Build dependency graph from test cases. @@ -129,8 +104,6 @@ def resolve_dep(src, dst): for v in adjacent: v.in_degree += 1 - _dfs(graph) - return graph, skipped_cases @@ -189,7 +162,8 @@ def validate_deps(graph): if n in path: cycle_str = '->'.join(path + [n]) raise DependencyError( - 'found cyclic dependency between tests: ' + cycle_str) + 'found cyclic dependency between tests: ' + cycle_str + ) if n not in visited: unvisited.append((n, node)) @@ -231,6 +205,7 @@ def toposort(graph, is_subgraph=False): ''' test_deps = _reduce_deps(graph) visited = util.OrderedSet() + levels = {} def retrieve(d, key, default): try: @@ -248,9 +223,15 @@ def visit(node, path): path.add(node) # Do a DFS visit of all the adjacent nodes - for adj in retrieve(test_deps, node, []): - if adj not in visited: - visit(adj, path) + adjacent = retrieve(test_deps, node, []) + for u in adjacent: + if u not in visited: + visit(u, path) + + if adjacent: + levels[node] = max(levels[u] for u in adjacent) + 1 + else: + levels[node] = 0 path.pop() visited.add(node) @@ -262,6 +243,7 @@ def visit(node, path): # Index test cases by test name cases_by_name = {} for c in graph.keys(): + c.level = levels[c.check.name] try: cases_by_name[c.check.name].append(c) except KeyError: diff --git a/unittests/test_dependencies.py b/unittests/test_dependencies.py index bdeebaaecc..18b6ce26c5 100644 --- a/unittests/test_dependencies.py +++ b/unittests/test_dependencies.py @@ -793,6 +793,18 @@ def test_toposort(make_test, exec_ctx): cases = dependencies.toposort(deps) assert_topological_order(cases, deps) + # Assert the level assignment + cases_by_level = {} + for c in cases: + cases_by_level.setdefault(c.level, set()) + cases_by_level[c.level].add(c.check.name) + + assert cases_by_level[0] == {'t0', 't5'} + assert cases_by_level[1] == {'t1', 't6', 't7'} + assert cases_by_level[2] == {'t2', 't8'} + assert cases_by_level[3] == {'t3'} + assert cases_by_level[4] == {'t4'} + def test_toposort_subgraph(make_test, exec_ctx): # @@ -825,3 +837,12 @@ def test_toposort_subgraph(make_test, exec_ctx): ) cases = dependencies.toposort(partial_deps, is_subgraph=True) assert_topological_order(cases, partial_deps) + + # Assert the level assignment + cases_by_level = {} + for c in cases: + cases_by_level.setdefault(c.level, set()) + cases_by_level[c.level].add(c.check.name) + + assert cases_by_level[1] == {'t3'} + assert cases_by_level[2] == {'t4'} From e0ba2559d13f6bd20564347bd1f80bc8e548b01b Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Tue, 2 Feb 2021 18:58:40 +0100 Subject: [PATCH 05/11] Remove unused import --- reframe/frontend/ci.py | 1 - 1 file changed, 1 deletion(-) diff --git a/reframe/frontend/ci.py b/reframe/frontend/ci.py index dacdc7de52..9c13486a24 100644 --- a/reframe/frontend/ci.py +++ b/reframe/frontend/ci.py @@ -3,7 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -import inspect import yaml import reframe From fcf0023fbe99795b9841ef361c194b778476c512 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Wed, 3 Feb 2021 19:24:56 +0100 Subject: [PATCH 06/11] Adapt YAML code generation to work properly with Gitlab --- reframe/frontend/ci.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/reframe/frontend/ci.py b/reframe/frontend/ci.py index 9c13486a24..dd1cccf927 100644 --- a/reframe/frontend/ci.py +++ b/reframe/frontend/ci.py @@ -3,9 +3,9 @@ # # SPDX-License-Identifier: BSD-3-Clause +import sys import yaml -import reframe import reframe.core.exceptions as errors import reframe.core.runtime as runtime @@ -14,11 +14,10 @@ def _emit_gitlab_pipeline(testcases): config = runtime.runtime().site_config # Collect the necessary ReFrame invariants - program = f'{reframe.INSTALL_PREFIX}/bin/reframe' + program = 'reframe' prefix = 'rfm-stage/${CI_COMMIT_SHORT_SHA}' checkpath = config.get('general/0/check_search_path') recurse = config.get('general/0/check_search_recursive') - report = 'rfm_report.json' def rfm_command(testcase): if config.filename != '': @@ -26,23 +25,36 @@ def rfm_command(testcase): else: config_opt = '' + report_file = f'rfm-report-{testcase.level}.json' + if testcase.level: + restore_file = f'rfm-report-{testcase.level - 1}.json' + else: + restore_file = None + return ' '.join([ program, f'--prefix={prefix}', config_opt, - f'{"-c ".join(checkpath)}', '-R' if recurse else '', - f'--report-file={report}', - f'--restore-session={report}' if testcase.level else '', + f'{" ".join("-c " + c for c in checkpath)}', + f'-R' if recurse else '', + f'--report-file={report_file}', + f'--restore-session={restore_file}' if restore_file else '', '-n', testcase.check.name, '-r' ]) max_level = 0 # We need the maximum level to generate the stages section - json = {'stages': []} + json = { + 'cache': { + 'key': '${CI_COMMIT_REF_SLUG}', + 'paths': ['rfm-stage/${CI_COMMIT_SHORT_SHA}'] + }, + 'stages': [] + } for tc in testcases: json[f'{tc.check.name}'] = { 'stage': f'rfm-stage-{tc.level}', 'script': [rfm_command(tc)], 'artifacts': { - 'paths': prefix + 'paths': [f'rfm-report-{tc.level}.json'] }, 'needs': [t.check.name for t in tc.deps] } @@ -57,4 +69,4 @@ def emit_pipeline(fp, testcases, backend='gitlab'): raise errors.ReframeError(f'unknown CI backend {backend!r}') yaml.dump(_emit_gitlab_pipeline(testcases), stream=fp, - indent=2, sort_keys=False) + indent=2, sort_keys=False, width=sys.maxsize) From 753b97a20bc37f5abe196d5e1256b1c2a02a0edc Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Thu, 4 Feb 2021 11:29:00 +0100 Subject: [PATCH 07/11] Add unit test for CI generate --- requirements.txt | 3 ++- unittests/test_ci.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 unittests/test_ci.py diff --git a/requirements.txt b/requirements.txt index 493c7462f0..47b9af384d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ +coverage==5.3 importlib_metadata==2.0.0 jsonschema==3.2.0 pytest==6.2.0 pytest-forked==1.3.0 pytest-parallel==0.1.0 -coverage==5.3 +requests==2.25.1 setuptools==50.3.0 wcwidth==0.2.5 PyYAML==5.3.1 diff --git a/unittests/test_ci.py b/unittests/test_ci.py new file mode 100644 index 0000000000..b0093cbfb4 --- /dev/null +++ b/unittests/test_ci.py @@ -0,0 +1,33 @@ +# Copyright 2016-2021 Swiss National Supercomputing Centre (CSCS/ETH Zurich) +# ReFrame Project Developers. See the top-level LICENSE file for details. +# +# SPDX-License-Identifier: BSD-3-Clause + + +import io +import pytest +import requests + +import reframe.frontend.ci as ci +import reframe.frontend.dependencies as dependencies +import reframe.frontend.executors as executors +from reframe.frontend.loader import RegressionCheckLoader + + +def test_ci_gitlab_pipeline(): + loader = RegressionCheckLoader([ + 'unittests/resources/checks_unlisted/deps_complex.py' + ]) + cases = dependencies.toposort( + dependencies.build_deps( + executors.generate_testcases(loader.load_all()) + )[0] + ) + with io.StringIO() as fp: + ci.emit_pipeline(fp, cases) + yaml = fp.getvalue() + + response = requests.post('https://gitlab.com/api/v4/ci/lint', + data={'content': {yaml}}) + assert response.ok + assert response.json()['status'] == 'valid' From 66e85a33f3bbaa7c99f7fc92128bff85e1097a0d Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Thu, 4 Feb 2021 11:33:56 +0100 Subject: [PATCH 08/11] Remove unused import from unit tests --- unittests/test_ci.py | 1 - 1 file changed, 1 deletion(-) diff --git a/unittests/test_ci.py b/unittests/test_ci.py index b0093cbfb4..946e96cdf7 100644 --- a/unittests/test_ci.py +++ b/unittests/test_ci.py @@ -5,7 +5,6 @@ import io -import pytest import requests import reframe.frontend.ci as ci From 57f231822115c2ec101265429f12bc446766d1a3 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Fri, 5 Feb 2021 14:09:54 +0100 Subject: [PATCH 09/11] Fine tune frontend output for the --ci-generate option --- reframe/core/exceptions.py | 7 +------ reframe/frontend/cli.py | 35 ++++++++++++++++++++++------------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/reframe/core/exceptions.py b/reframe/core/exceptions.py index b2267c2f15..16905345bd 100644 --- a/reframe/core/exceptions.py +++ b/reframe/core/exceptions.py @@ -308,13 +308,8 @@ def is_severe(exc_type, exc_value, tb): '''Check if exception is a severe one.''' soft_errors = (ReframeError, - ConnectionError, - FileExistsError, - FileNotFoundError, - IsADirectoryError, + OSError, KeyboardInterrupt, - NotADirectoryError, - PermissionError, TimeoutError) if isinstance(exc_value, soft_errors): return False diff --git a/reframe/frontend/cli.py b/reframe/frontend/cli.py index dc914fe62d..171a4f3283 100644 --- a/reframe/frontend/cli.py +++ b/reframe/frontend/cli.py @@ -120,7 +120,7 @@ def list_checks(testcases, printer, detailed=False): printer.info( '\n'.join(format_check(c, deps[c.name], detailed) for c in checks) ) - printer.info(f'Found {len(checks)} check(s)') + printer.info(f'Found {len(checks)} check(s)\n') def logfiles_message(): @@ -273,6 +273,11 @@ def main(): '-r', '--run', action='store_true', help='Run the selected checks' ) + action_options.add_argument( + '--ci-generate', action='store', metavar='FILE', + help=('Generate into FILE a Gitlab CI pipeline ' + 'for the selected tests and exit'), + ) # Run options run_options.add_argument( @@ -335,10 +340,6 @@ def main(): '--disable-hook', action='append', metavar='NAME', dest='hooks', default=[], help='Disable a pipeline hook for this run' ) - run_options.add_argument( - '--ci-generate', action='store', metavar='FILE', - help="Store ci pipeline in yaml FILE", - ) # Environment options env_options.add_argument( @@ -793,12 +794,6 @@ def _case_failed(t): ) printer.verbose(f'Final number of test cases: {len(testcases)}') - if options.ci_generate: - with open(options.ci_generate, 'wt') as fp: - ci.emit_pipeline(fp, testcases) - - sys.exit(0) - # Disable hooks for tc in testcases: for h in options.hooks: @@ -809,9 +804,23 @@ def _case_failed(t): list_checks(testcases, printer, options.list_detailed) sys.exit(0) + if options.ci_generate: + list_checks(testcases, printer) + printer.info('[Generate CI]') + with open(options.ci_generate, 'wt') as fp: + ci.emit_pipeline(fp, testcases) + + printer.info( + f' Gitlab pipeline generated successfully ' + f'in {options.ci_generate!r}.\n' + ) + sys.exit(0) + if not options.run: - printer.error(f"No action specified. Please specify `-l'/`-L' for " - f"listing or `-r' for running. " + printer.error("No action option specified. Available options:\n" + " - `-l'/`-L' for listing\n" + " - `-r' for running\n" + " - `--ci-generate' for generating a CI pipeline\n" f"Try `{argparser.prog} -h' for more options.") sys.exit(1) From 2831af08e067b32195c523c6585fa26ff4f354ce Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Fri, 5 Feb 2021 23:36:43 +0100 Subject: [PATCH 10/11] Document the `--ci-generate` option --- docs/_static/img/gitlab-ci.png | Bin 0 -> 78036 bytes docs/manpage.rst | 8 +++++ docs/tutorial_tips_tricks.rst | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 docs/_static/img/gitlab-ci.png diff --git a/docs/_static/img/gitlab-ci.png b/docs/_static/img/gitlab-ci.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd780378b94cb56a682264c1dada979828e59cd GIT binary patch literal 78036 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≠!$apK_G*@u?A!d7I=k`m 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 z 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 VQpCTnsb2+OTAI2V>4z-*{|`nI8ioJ> literal 0 HcmV?d00001 diff --git a/docs/manpage.rst b/docs/manpage.rst index 315196b879..88b7bfefe7 100644 --- a/docs/manpage.rst +++ b/docs/manpage.rst @@ -147,6 +147,14 @@ There are currently two actions that can be performed on tests: (a) list the tes An action must always be specified. +.. option:: --ci-generate=FILE + + Do not run the tests, but generate a Gitlab `child pipeline `__ specification in ``FILE``. + You can set up your Gitlab CI to use the generated file to run every test as a separate Gitlab job respecting test dependencies. + For more information, have a look in :ref:`generate-ci-pipeline`. + + .. versionadded:: 3.5 + .. option:: -l, --list List selected tests. diff --git a/docs/tutorial_tips_tricks.rst b/docs/tutorial_tips_tricks.rst index 63ec393ccd..865ff77a35 100644 --- a/docs/tutorial_tips_tricks.rst +++ b/docs/tutorial_tips_tricks.rst @@ -367,6 +367,8 @@ This option is useful when you combine it with the various test filtering option For example, you might want to rerun only the failed tests or just a specific test in a dependency chain. Let's see an artificial example that uses the following test dependency graph. +.. _fig-deps-complex: + .. figure:: _static/img/deps-complex.svg :align: center @@ -477,3 +479,60 @@ If we tried to run :class:`T6` without restoring the session, we would have to r [ PASSED ] Ran 5 test case(s) from 5 check(s) (0 failure(s)) [==========] Finished on Thu Jan 21 14:32:09 2021 + + +.. _generate-ci-pipeline: + +Integrating into a CI pipeline +------------------------------ + +.. versionadded:: 3.5 + +Instead of running your tests, you can ask ReFrame to generate a `child pipeline `__ specification for the Gitlab CI. +This will spawn a CI job for each ReFrame test respecting test dependencies. +You could run your tests in a single job of your Gitlab pipeline, but you would not take advantage of the parallelism across different CI jobs. +Having a separate CI job per test makes it also easier to spot the failing tests. + +As soon as you have set up a `runner `__ for your repository, it is fairly straightforward to use ReFrame to automatically generate the necessary CI steps. +The following is an example of ``.gitlab-ci.yml`` file that does exactly that: + +.. code-block:: yaml + + stages: + - generate + - test + + generate-pipeline: + stage: generate + script: + - reframe --ci-generate=${CI_PROJECT_DIR}/pipeline.yml -c ${CI_PROJECT_DIR}/path/to/tests + artifacts: + paths: + - ${CI_PROJECT_DIR}/pipeline.yml + + test-jobs: + stage: test + trigger: + include: + - artifact: pipeline.yml + job: generate-pipeline + strategy: depend + + +It defines two stages. +The first one, called ``generate``, will call ReFrame to generate the pipeline specification for the desired tests. +All the usual `test selection options `__ can be used to select specific tests. +ReFrame will process them as usual, but instead of running them, it will generate the correct steps for running them in Gitlab. +We then pass the generated CI pipeline file to second phase as an artifact and we are done! + +The following figure shows one part of the automatically generated pipeline for the test graph depicted `above <#fig-deps-complex>`__. + +.. figure:: _static/img/gitlab-ci.png + :align: center + + :sub:`Snapshot of a Gitlab pipeline generated automatically by ReFrame.` + + +.. note:: + + The ReFrame executable must be available in the Gitlab runner that will run the CI jobs. From 4cce474a1e2e77aa6fe28a12d0b49e5d60120f86 Mon Sep 17 00:00:00 2001 From: Vasileios Karakasis Date: Mon, 8 Feb 2021 16:44:29 +0100 Subject: [PATCH 11/11] Address PR comments. --- docs/manpage.rst | 2 +- docs/tutorial_tips_tricks.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/manpage.rst b/docs/manpage.rst index 88b7bfefe7..b759cb1ed0 100644 --- a/docs/manpage.rst +++ b/docs/manpage.rst @@ -153,7 +153,7 @@ An action must always be specified. You can set up your Gitlab CI to use the generated file to run every test as a separate Gitlab job respecting test dependencies. For more information, have a look in :ref:`generate-ci-pipeline`. - .. versionadded:: 3.5 + .. versionadded:: 3.4.1 .. option:: -l, --list diff --git a/docs/tutorial_tips_tricks.rst b/docs/tutorial_tips_tricks.rst index 865ff77a35..c8a63377c6 100644 --- a/docs/tutorial_tips_tricks.rst +++ b/docs/tutorial_tips_tricks.rst @@ -486,7 +486,7 @@ If we tried to run :class:`T6` without restoring the session, we would have to r Integrating into a CI pipeline ------------------------------ -.. versionadded:: 3.5 +.. versionadded:: 3.4.1 Instead of running your tests, you can ask ReFrame to generate a `child pipeline `__ specification for the Gitlab CI. This will spawn a CI job for each ReFrame test respecting test dependencies. @@ -522,7 +522,7 @@ The following is an example of ``.gitlab-ci.yml`` file that does exactly that: It defines two stages. The first one, called ``generate``, will call ReFrame to generate the pipeline specification for the desired tests. All the usual `test selection options `__ can be used to select specific tests. -ReFrame will process them as usual, but instead of running them, it will generate the correct steps for running them in Gitlab. +ReFrame will process them as usual, but instead of running the selected tests, it will generate the correct steps for running each test individually as a Gitlab job. We then pass the generated CI pipeline file to second phase as an artifact and we are done! The following figure shows one part of the automatically generated pipeline for the test graph depicted `above <#fig-deps-complex>`__.