diff --git a/reframe/core/pipeline.py b/reframe/core/pipeline.py index 22ade988ca..65e09e2cef 100644 --- a/reframe/core/pipeline.py +++ b/reframe/core/pipeline.py @@ -962,6 +962,29 @@ def pipeline_hooks(cls): #: :default: :class:`True` build_locally = variable(typ.Bool, value=True, loggable=True) + #: .. versionadded:: 4.2 + #: + #: Extra options to be passed to the child CI pipeline generated for this + #: test using the :option:`--ci-generate` option. + #: + #: This variable is a dictionary whose keys refer the CI generate backend + #: and the values can be in any CI backend-specific format. + #: + #: Currently, the only key supported is ``'gitlab'`` and the values is a + #: Gitlab configuration in JSON format. For example, if we want a pipeline + #: to run only when files in ``backend`` or ``src/main.c`` have changed, + #: this variable should be set as follows: + #: + #: .. code-block:: python + #: + #: ci_extras = { + #: 'only': {'changes': ['backend/*', 'src/main.c']} + #: } + #: + #: :type: :class:`dict` + #: :default: ``{}`` + ci_extras = variable(typ.Dict[typ.Str['gitlab'], object], value={}) + # Special variables #: Dry-run mode diff --git a/reframe/frontend/ci.py b/reframe/frontend/ci.py index bd48df85ef..e4cd08257a 100644 --- a/reframe/frontend/ci.py +++ b/reframe/frontend/ci.py @@ -7,8 +7,8 @@ import sys import yaml -import reframe.core.exceptions as errors import reframe.core.runtime as runtime +from reframe.core.exceptions import ReframeError def _emit_gitlab_pipeline(testcases, child_pipeline_opts): @@ -46,6 +46,19 @@ def rfm_command(testcase): *child_pipeline_opts ]) + def _valid_ci_extras(extras): + '''Validate Gitlab CI pipeline extras''' + + for kwd in ('stage', 'script', 'needs'): + if kwd in extras: + errmsg = f"invalid keyword found: {kwd!r}" + if kwd == 'script': + errmsg += " (use 'before_script' or 'after_script')" + + raise ReframeError(f"could not validate 'ci_extras': {errmsg}") + + return extras + max_level = 0 # We need the maximum level to generate the stages section json = { 'cache': { @@ -62,13 +75,19 @@ def rfm_command(testcase): json['image'] = image_name for tc in testcases: + ci_extras = _valid_ci_extras(tc.check.ci_extras.get('gitlab', {})) + extra_artifacts = ci_extras.pop('artifacts', {}) + extra_artifact_paths = extra_artifacts.pop('paths', []) json[f'{tc.check.unique_name}'] = { 'stage': f'rfm-stage-{tc.level}', 'script': [rfm_command(tc)], 'artifacts': { - 'paths': [f'{tc.check.unique_name}-report.json'] + 'paths': [f'{tc.check.unique_name}-report.json', + *extra_artifact_paths], + **extra_artifacts }, - 'needs': [t.check.unique_name for t in tc.deps] + 'needs': [t.check.unique_name for t in tc.deps], + **ci_extras } max_level = max(max_level, tc.level) @@ -78,7 +97,7 @@ def rfm_command(testcase): def emit_pipeline(fp, testcases, child_pipeline_opts=None, backend='gitlab'): if backend != 'gitlab': - raise errors.ReframeError(f'unknown CI backend {backend!r}') + raise ReframeError(f'unknown CI backend {backend!r}') child_pipeline_opts = child_pipeline_opts or [] yaml.dump(_emit_gitlab_pipeline(testcases, child_pipeline_opts), stream=fp, diff --git a/unittests/test_ci.py b/unittests/test_ci.py index 0756db5577..5e7896c29e 100644 --- a/unittests/test_ci.py +++ b/unittests/test_ci.py @@ -13,18 +13,27 @@ import reframe.frontend.ci as ci import reframe.frontend.dependencies as dependencies import reframe.frontend.executors as executors +from reframe.core.exceptions import ReframeError from reframe.frontend.loader import RegressionCheckLoader +def _generate_test_cases(checks): + return dependencies.toposort( + dependencies.build_deps(executors.generate_testcases(checks))[0] + ) + + +@pytest.fixture +def hello_test(): + from unittests.resources.checks.hellocheck import HelloTest + return HelloTest() + + 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] - ) + cases = _generate_test_cases(loader.load_all()) with io.StringIO() as fp: ci.emit_pipeline(fp, cases) pipeline = fp.getvalue() @@ -41,3 +50,55 @@ def test_ci_gitlab_pipeline(): schema = response.json() jsonschema.validate(yaml.safe_load(pipeline), schema) + + +def test_ci_gitlab_ci_extras(hello_test): + hello_test.ci_extras = { + 'gitlab': { + 'before_script': ['touch foo.txt'], + 'after_script': ['echo done'], + 'artifacts': { + 'paths': ['foo.txt'] + }, + 'only': { + 'changes': ['src/foo.c'] + } + } + } + cases = _generate_test_cases([hello_test]) + with io.StringIO() as fp: + ci.emit_pipeline(fp, cases) + pipeline = fp.getvalue() + + pipeline_json = yaml.safe_load(pipeline)['HelloTest'] + assert pipeline_json['before_script'] == ['touch foo.txt'] + assert pipeline_json['after_script'] == ['echo done'] + assert pipeline_json['artifacts'] == { + 'paths': ['HelloTest-report.json', 'foo.txt'] + } + assert pipeline_json['only'] == {'changes': ['src/foo.c']} + + +def test_ci_gitlab_ci_extras_invalid(hello_test): + with pytest.raises(TypeError): + hello_test.ci_extras = { + 'before_script': ['touch foo.txt'] + } + + with pytest.raises(TypeError): + hello_test.ci_extras = { + 'foolab': { + 'before_script': ['touch foo.txt'] + } + } + + # Check invalid keywords + for kwd in ('stage', 'script', 'needs'): + hello_test.ci_extras = { + 'gitlab': {kwd: 'something'} + } + cases = _generate_test_cases([hello_test]) + with io.StringIO() as fp: + with pytest.raises(ReframeError, + match=rf'invalid keyword found: {kwd!r}'): + ci.emit_pipeline(fp, cases)