Skip to content

Commit 78242ff

Browse files
author
Vasileios Karakasis
authored
Merge pull request #1641 from victorusu/ci-generate-pipeline-new
[feat] Add new action for generating dynamic Gitlab pipelines
2 parents 07459a0 + 8faebb4 commit 78242ff

File tree

11 files changed

+238
-15
lines changed

11 files changed

+238
-15
lines changed

docs/_static/img/gitlab-ci.png

76.2 KB
Loading

docs/manpage.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ There are currently two actions that can be performed on tests: (a) list the tes
147147
An action must always be specified.
148148

149149

150+
.. option:: --ci-generate=FILE
151+
152+
Do not run the tests, but generate a Gitlab `child pipeline <https://docs.gitlab.com/ee/ci/parent_child_pipelines.html>`__ specification in ``FILE``.
153+
You can set up your Gitlab CI to use the generated file to run every test as a separate Gitlab job respecting test dependencies.
154+
For more information, have a look in :ref:`generate-ci-pipeline`.
155+
156+
.. versionadded:: 3.4.1
157+
150158
.. option:: -l, --list
151159

152160
List selected tests.

docs/tutorial_tips_tricks.rst

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,8 @@ This option is useful when you combine it with the various test filtering option
367367
For example, you might want to rerun only the failed tests or just a specific test in a dependency chain.
368368
Let's see an artificial example that uses the following test dependency graph.
369369

370+
.. _fig-deps-complex:
371+
370372
.. figure:: _static/img/deps-complex.svg
371373
:align: center
372374

@@ -477,3 +479,60 @@ If we tried to run :class:`T6` without restoring the session, we would have to r
477479
478480
[ PASSED ] Ran 5 test case(s) from 5 check(s) (0 failure(s))
479481
[==========] Finished on Thu Jan 21 14:32:09 2021
482+
483+
484+
.. _generate-ci-pipeline:
485+
486+
Integrating into a CI pipeline
487+
------------------------------
488+
489+
.. versionadded:: 3.4.1
490+
491+
Instead of running your tests, you can ask ReFrame to generate a `child pipeline <https://docs.gitlab.com/ee/ci/parent_child_pipelines.html>`__ specification for the Gitlab CI.
492+
This will spawn a CI job for each ReFrame test respecting test dependencies.
493+
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.
494+
Having a separate CI job per test makes it also easier to spot the failing tests.
495+
496+
As soon as you have set up a `runner <https://docs.gitlab.com/ee/ci/quick_start/>`__ for your repository, it is fairly straightforward to use ReFrame to automatically generate the necessary CI steps.
497+
The following is an example of ``.gitlab-ci.yml`` file that does exactly that:
498+
499+
.. code-block:: yaml
500+
501+
stages:
502+
- generate
503+
- test
504+
505+
generate-pipeline:
506+
stage: generate
507+
script:
508+
- reframe --ci-generate=${CI_PROJECT_DIR}/pipeline.yml -c ${CI_PROJECT_DIR}/path/to/tests
509+
artifacts:
510+
paths:
511+
- ${CI_PROJECT_DIR}/pipeline.yml
512+
513+
test-jobs:
514+
stage: test
515+
trigger:
516+
include:
517+
- artifact: pipeline.yml
518+
job: generate-pipeline
519+
strategy: depend
520+
521+
522+
It defines two stages.
523+
The first one, called ``generate``, will call ReFrame to generate the pipeline specification for the desired tests.
524+
All the usual `test selection options <manpage.html#test-filtering>`__ can be used to select specific tests.
525+
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.
526+
We then pass the generated CI pipeline file to second phase as an artifact and we are done!
527+
528+
The following figure shows one part of the automatically generated pipeline for the test graph depicted `above <#fig-deps-complex>`__.
529+
530+
.. figure:: _static/img/gitlab-ci.png
531+
:align: center
532+
533+
:sub:`Snapshot of a Gitlab pipeline generated automatically by ReFrame.`
534+
535+
536+
.. note::
537+
538+
The ReFrame executable must be available in the Gitlab runner that will run the CI jobs.

reframe/core/exceptions.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,8 @@ def is_severe(exc_type, exc_value, tb):
308308
'''Check if exception is a severe one.'''
309309

310310
soft_errors = (ReframeError,
311-
ConnectionError,
312-
FileExistsError,
313-
FileNotFoundError,
314-
IsADirectoryError,
311+
OSError,
315312
KeyboardInterrupt,
316-
NotADirectoryError,
317-
PermissionError,
318313
TimeoutError)
319314
if isinstance(exc_value, soft_errors):
320315
return False

reframe/frontend/ci.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2016-2020 Swiss National Supercomputing Centre (CSCS/ETH Zurich)
2+
# ReFrame Project Developers. See the top-level LICENSE file for details.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
import sys
7+
import yaml
8+
9+
import reframe.core.exceptions as errors
10+
import reframe.core.runtime as runtime
11+
12+
13+
def _emit_gitlab_pipeline(testcases):
14+
config = runtime.runtime().site_config
15+
16+
# Collect the necessary ReFrame invariants
17+
program = 'reframe'
18+
prefix = 'rfm-stage/${CI_COMMIT_SHORT_SHA}'
19+
checkpath = config.get('general/0/check_search_path')
20+
recurse = config.get('general/0/check_search_recursive')
21+
22+
def rfm_command(testcase):
23+
if config.filename != '<builtin>':
24+
config_opt = f'-C {config.filename}'
25+
else:
26+
config_opt = ''
27+
28+
report_file = f'rfm-report-{testcase.level}.json'
29+
if testcase.level:
30+
restore_file = f'rfm-report-{testcase.level - 1}.json'
31+
else:
32+
restore_file = None
33+
34+
return ' '.join([
35+
program,
36+
f'--prefix={prefix}', config_opt,
37+
f'{" ".join("-c " + c for c in checkpath)}',
38+
f'-R' if recurse else '',
39+
f'--report-file={report_file}',
40+
f'--restore-session={restore_file}' if restore_file else '',
41+
'-n', testcase.check.name, '-r'
42+
])
43+
44+
max_level = 0 # We need the maximum level to generate the stages section
45+
json = {
46+
'cache': {
47+
'key': '${CI_COMMIT_REF_SLUG}',
48+
'paths': ['rfm-stage/${CI_COMMIT_SHORT_SHA}']
49+
},
50+
'stages': []
51+
}
52+
for tc in testcases:
53+
json[f'{tc.check.name}'] = {
54+
'stage': f'rfm-stage-{tc.level}',
55+
'script': [rfm_command(tc)],
56+
'artifacts': {
57+
'paths': [f'rfm-report-{tc.level}.json']
58+
},
59+
'needs': [t.check.name for t in tc.deps]
60+
}
61+
max_level = max(max_level, tc.level)
62+
63+
json['stages'] = [f'rfm-stage-{m}' for m in range(max_level+1)]
64+
return json
65+
66+
67+
def emit_pipeline(fp, testcases, backend='gitlab'):
68+
if backend != 'gitlab':
69+
raise errors.ReframeError(f'unknown CI backend {backend!r}')
70+
71+
yaml.dump(_emit_gitlab_pipeline(testcases), stream=fp,
72+
indent=2, sort_keys=False, width=sys.maxsize)

reframe/frontend/cli.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import reframe.core.runtime as runtime
2222
import reframe.core.warnings as warnings
2323
import reframe.frontend.argparse as argparse
24+
import reframe.frontend.ci as ci
2425
import reframe.frontend.dependencies as dependencies
2526
import reframe.frontend.filters as filters
2627
import reframe.frontend.runreport as runreport
@@ -119,7 +120,7 @@ def list_checks(testcases, printer, detailed=False):
119120
printer.info(
120121
'\n'.join(format_check(c, deps[c.name], detailed) for c in checks)
121122
)
122-
printer.info(f'Found {len(checks)} check(s)')
123+
printer.info(f'Found {len(checks)} check(s)\n')
123124

124125

125126
def logfiles_message():
@@ -272,6 +273,11 @@ def main():
272273
'-r', '--run', action='store_true',
273274
help='Run the selected checks'
274275
)
276+
action_options.add_argument(
277+
'--ci-generate', action='store', metavar='FILE',
278+
help=('Generate into FILE a Gitlab CI pipeline '
279+
'for the selected tests and exit'),
280+
)
275281

276282
# Run options
277283
run_options.add_argument(
@@ -334,6 +340,8 @@ def main():
334340
'--disable-hook', action='append', metavar='NAME', dest='hooks',
335341
default=[], help='Disable a pipeline hook for this run'
336342
)
343+
344+
# Environment options
337345
env_options.add_argument(
338346
'-M', '--map-module', action='append', metavar='MAPPING',
339347
dest='module_mappings', default=[],
@@ -795,9 +803,23 @@ def _case_failed(t):
795803
list_checks(testcases, printer, options.list_detailed)
796804
sys.exit(0)
797805

806+
if options.ci_generate:
807+
list_checks(testcases, printer)
808+
printer.info('[Generate CI]')
809+
with open(options.ci_generate, 'wt') as fp:
810+
ci.emit_pipeline(fp, testcases)
811+
812+
printer.info(
813+
f' Gitlab pipeline generated successfully '
814+
f'in {options.ci_generate!r}.\n'
815+
)
816+
sys.exit(0)
817+
798818
if not options.run:
799-
printer.error(f"No action specified. Please specify `-l'/`-L' for "
800-
f"listing or `-r' for running. "
819+
printer.error("No action option specified. Available options:\n"
820+
" - `-l'/`-L' for listing\n"
821+
" - `-r' for running\n"
822+
" - `--ci-generate' for generating a CI pipeline\n"
801823
f"Try `{argparser.prog} -h' for more options.")
802824
sys.exit(1)
803825

reframe/frontend/dependencies.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ def validate_deps(graph):
170170
if n in path:
171171
cycle_str = '->'.join(path + [n])
172172
raise DependencyError(
173-
'found cyclic dependency between tests: ' + cycle_str)
173+
'found cyclic dependency between tests: ' + cycle_str
174+
)
174175

175176
if n not in visited:
176177
unvisited.append((n, node))
@@ -212,6 +213,7 @@ def toposort(graph, is_subgraph=False):
212213
'''
213214
test_deps = _reduce_deps(graph)
214215
visited = util.OrderedSet()
216+
levels = {}
215217

216218
def retrieve(d, key, default):
217219
try:
@@ -229,9 +231,15 @@ def visit(node, path):
229231
path.add(node)
230232

231233
# Do a DFS visit of all the adjacent nodes
232-
for adj in retrieve(test_deps, node, []):
233-
if adj not in visited:
234-
visit(adj, path)
234+
adjacent = retrieve(test_deps, node, [])
235+
for u in adjacent:
236+
if u not in visited:
237+
visit(u, path)
238+
239+
if adjacent:
240+
levels[node] = max(levels[u] for u in adjacent) + 1
241+
else:
242+
levels[node] = 0
235243

236244
path.pop()
237245
visited.add(node)
@@ -243,6 +251,7 @@ def visit(node, path):
243251
# Index test cases by test name
244252
cases_by_name = {}
245253
for c in graph.keys():
254+
c.level = levels[c.check.name]
246255
try:
247256
cases_by_name[c.check.name].append(c)
248257
except KeyError:

reframe/frontend/executors/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ def __init__(self, check, partition, environ):
4444
# Incoming dependencies
4545
self.in_degree = 0
4646

47+
# Level in the dependency chain
48+
self.level = 0
49+
4750
def __iter__(self):
4851
# Allow unpacking a test case with a single liner:
4952
# c, p, e = case

requirements.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
argcomplete==1.12.2
2+
coverage==5.3
13
importlib_metadata==2.0.0
24
jsonschema==3.2.0
35
pytest==6.2.0
46
pytest-forked==1.3.0
57
pytest-parallel==0.1.0
6-
coverage==5.3
8+
PyYAML==5.3.1
9+
requests==2.25.1
710
setuptools==50.3.0
811
wcwidth==0.2.5
9-
argcomplete==1.12.2
1012
#+pygelf%pygelf==0.3.6

unittests/test_ci.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright 2016-2021 Swiss National Supercomputing Centre (CSCS/ETH Zurich)
2+
# ReFrame Project Developers. See the top-level LICENSE file for details.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
7+
import io
8+
import requests
9+
10+
import reframe.frontend.ci as ci
11+
import reframe.frontend.dependencies as dependencies
12+
import reframe.frontend.executors as executors
13+
from reframe.frontend.loader import RegressionCheckLoader
14+
15+
16+
def test_ci_gitlab_pipeline():
17+
loader = RegressionCheckLoader([
18+
'unittests/resources/checks_unlisted/deps_complex.py'
19+
])
20+
cases = dependencies.toposort(
21+
dependencies.build_deps(
22+
executors.generate_testcases(loader.load_all())
23+
)[0]
24+
)
25+
with io.StringIO() as fp:
26+
ci.emit_pipeline(fp, cases)
27+
yaml = fp.getvalue()
28+
29+
response = requests.post('https://gitlab.com/api/v4/ci/lint',
30+
data={'content': {yaml}})
31+
assert response.ok
32+
assert response.json()['status'] == 'valid'

0 commit comments

Comments
 (0)