diff --git a/module_build_service/builder.py b/module_build_service/builder.py index f2cd06a1..5832a8c6 100644 --- a/module_build_service/builder.py +++ b/module_build_service/builder.py @@ -231,6 +231,15 @@ class GenericBuilder(six.with_metaclass(ABCMeta)): """ raise NotImplementedError() + @abstractmethod + def cancel_build(self, task_id): + """ + :param task_id: Task ID returned by the build method. + + Cancels the build. + """ + raise NotImplementedError() + @classmethod @abstractmethod def repo_from_tag(self, config, tag_name, arch): @@ -568,6 +577,9 @@ chmod 644 %buildroot/%_rpmconfigdir/macros.d/macros.modules reason = "Failed to submit artifact %s to Koji" % (artifact_name) return task_id, state, reason, None + def cancel_build(self, task_id): + self.koji_session.cancelTask(task_id) + @classmethod def repo_from_tag(cls, config, tag_name, arch): """ @@ -910,6 +922,8 @@ class CoprModuleBuilder(GenericBuilder): raise ValueError(response["error"]) return response["repo"] + def cancel_build(self, task_id): + pass class MockModuleBuilder(GenericBuilder): """ @@ -1216,6 +1230,9 @@ $repos # @FIXME return KojiModuleBuilder.get_disttag_srpm(disttag) + def cancel_build(self, task_id): + pass + GenericBuilder.register_backend_class(KojiModuleBuilder) GenericBuilder.register_backend_class(CoprModuleBuilder) GenericBuilder.register_backend_class(MockModuleBuilder) diff --git a/module_build_service/scheduler/handlers/modules.py b/module_build_service/scheduler/handlers/modules.py index 4911d3ff..2c4c5cda 100644 --- a/module_build_service/scheduler/handlers/modules.py +++ b/module_build_service/scheduler/handlers/modules.py @@ -44,6 +44,57 @@ def get_rpm_release_from_tag(tag): def get_artifact_from_srpm(srpm_path): return os.path.basename(srpm_path).replace(".src.rpm", "") +def failed(config, session, msg): + """ + Called whenever a module enters the 'failed' state. + + We cancel all the remaining component builds of a module + and stop the building. + """ + + build = models.ModuleBuild.from_module_event(session, msg) + + module_info = build.json() + if module_info['state'] != msg.module_build_state: + log.warn("Note that retrieved module state %r " + "doesn't match message module state %r" % ( + module_info['state'], msg.module_build_state)) + # This is ok.. it's a race condition we can ignore. + pass + + unbuilt_components = [ + c for c in build.component_builds + if (c.state != koji.BUILD_STATES['COMPLETE'] + and c.state != koji.BUILD_STATES["FAILED"]) + ] + + try: + groups = { + 'build': build.resolve_profiles(session, 'buildroot'), + 'srpm-build': build.resolve_profiles(session, 'srpm-buildroot'), + } + except ValueError: + reason = "Failed to gather buildroot groups from SCM." + log.exception(reason) + build.transition(config, state="failed", state_reason=reason) + session.commit() + raise + + builder = module_build_service.builder.GenericBuilder.create( + build.owner, build.name, config.system, config, tag_name=build.koji_tag) + builder.buildroot_connect(groups) + + for component in unbuilt_components: + if component.task_id: + builder.cancel_build(component.task_id) + component.state = koji.BUILD_STATES['FAILED'] + component.state_reason = build.state_reason + session.add(component) + + build.transition(config, state="failed") + session.commit() + + def done(config, session, msg): """Called whenever a module enters the 'done' state. diff --git a/module_build_service/scheduler/main.py b/module_build_service/scheduler/main.py index 42545706..2cfa01da 100644 --- a/module_build_service/scheduler/main.py +++ b/module_build_service/scheduler/main.py @@ -102,7 +102,7 @@ class MessageWorker(threading.Thread): models.BUILD_STATES["init"]: NO_OP, models.BUILD_STATES["wait"]: module_build_service.scheduler.handlers.modules.wait, models.BUILD_STATES["build"]: NO_OP, - models.BUILD_STATES["failed"]: NO_OP, + models.BUILD_STATES["failed"]: module_build_service.scheduler.handlers.modules.failed, models.BUILD_STATES["done"]: module_build_service.scheduler.handlers.modules.done, # XXX: DIRECT TRANSITION TO READY models.BUILD_STATES["ready"]: NO_OP, } diff --git a/module_build_service/views.py b/module_build_service/views.py index 384aa3b9..4b7e7c03 100644 --- a/module_build_service/views.py +++ b/module_build_service/views.py @@ -35,7 +35,7 @@ from flask import request, jsonify from flask.views import MethodView from module_build_service import app, conf, log -from module_build_service import models +from module_build_service import models, db from module_build_service.utils import pagination_metadata, filter_module_builds, submit_module_build from module_build_service.errors import ( ValidationError, Unauthorized, NotFound) @@ -60,6 +60,12 @@ api_v1 = { 'methods': ['GET'], } }, + 'module_build_cancel': { + 'url': '/module-build-service/1/module-builds/cancel/', + 'options': { + 'methods': ['PUT'] + } + }, } @@ -126,6 +132,32 @@ class ModuleBuildAPI(MethodView): module = submit_module_build(username, url) return jsonify(module.json()), 201 + def put(self, id): + username = module_build_service.auth.get_username(request.environ) + + if conf.require_packager: + module_build_service.auth.assert_is_packager(username, fas_kwargs=dict( + base_url=conf.fas_url, + username=conf.fas_username, + password=conf.fas_password)) + + if id is None: + raise NotFound('You must provide module build id.') + + module = models.ModuleBuild.query.filter_by(id=id).first() + if not module: + raise NotFound('No such module found.') + + if module.owner != username: + raise Unauthorized("You are not owner of this build and " + "therefore cannot cancel it.") + + module.transition(conf, models.BUILD_STATES["failed"], + "Canceled by %s." % username) + db.session.add(module) + db.session.commit() + + return jsonify(module.api_json()), 200 def register_api_v1(): """ Registers version 1 of Rida API. """ diff --git a/tests/test_build/test_build.py b/tests/test_build/test_build.py index 449cb3ad..94145546 100644 --- a/tests/test_build/test_build.py +++ b/tests/test_build/test_build.py @@ -77,11 +77,22 @@ class TestModuleBuilder(GenericBuilder): # Global build_id/task_id we increment when new build is executed. _build_id = 1 + BUILD_STATE = "COMPLETE" + + on_build_cb = None + on_cancel_cb = None + def __init__(self, owner, module, config, tag_name): self.module_str = module self.tag_name = tag_name self.config = config + @classmethod + def reset(cls): + TestModuleBuilder.BUILD_STATE = "COMPLETE" + TestModuleBuilder.on_build_cb = None + TestModuleBuilder.on_cancel_cb = None + def buildroot_connect(self, groups): pass @@ -129,10 +140,15 @@ class TestModuleBuilder(GenericBuilder): TestModuleBuilder._build_id += 1 - self._send_repo_done() - self._send_build_change(koji.BUILD_STATES['COMPLETE'], source, - TestModuleBuilder._build_id) - self._send_repo_done() + if TestModuleBuilder.BUILD_STATE != "BUILDING": + self._send_repo_done() + self._send_build_change( + koji.BUILD_STATES[TestModuleBuilder.BUILD_STATE], source, + TestModuleBuilder._build_id) + self._send_repo_done() + + if TestModuleBuilder.on_build_cb: + TestModuleBuilder.on_build_cb(self, artifact_name, source) state = koji.BUILD_STATES['BUILDING'] reason = "Submitted %s to Koji" % (artifact_name) @@ -143,6 +159,10 @@ class TestModuleBuilder(GenericBuilder): # @FIXME return KojiModuleBuilder.get_disttag_srpm(disttag) + def cancel_build(self, task_id): + if TestModuleBuilder.on_cancel_cb: + TestModuleBuilder.on_cancel_cb(self, task_id) + def set_dburi(dburi): """ Sets database URI in all places in the middle of test. @@ -175,6 +195,7 @@ class TestBuild(unittest.TestCase): # Set back the original database URI set_dburi(self.orig_dburi) conf.set_item("system", "koji") + TestModuleBuilder.reset() @patch('module_build_service.auth.get_username', return_value='Homer J. Simpson') @patch('module_build_service.auth.assert_is_packager') @@ -203,3 +224,53 @@ class TestBuild(unittest.TestCase): for build in models.ComponentBuild.query.filter_by(module_id=module_build_id).all(): self.assertEqual(build.state, koji.BUILD_STATES['COMPLETE']) self.assertTrue(build.module_build.state in [models.BUILD_STATES["done"], models.BUILD_STATES["ready"]] ) + + @patch('module_build_service.auth.get_username', return_value='Homer J. Simpson') + @patch('module_build_service.auth.assert_is_packager') + @patch('module_build_service.scm.SCM') + def test_submit_build_cancel(self, mocked_scm, mocked_assert_is_packager, + mocked_get_username): + """ + Submit all builds for a module and cancel the module build later. + """ + mocked_scm_obj = MockedSCM(mocked_scm, "testmodule", "testmodule.yaml") + + rv = self.client.post('/module-build-service/1/module-builds/', data=json.dumps( + {'scmurl': 'git://pkgs.stg.fedoraproject.org/modules/' + 'testmodule.git?#68932c90de214d9d13feefbd35246a81b6cb8d49'})) + + data = json.loads(rv.data) + module_build_id = data['id'] + + # This callback is called before return of TestModuleBuilder.build() + # method. We just cancel the build here using the web API to simulate + # user cancelling the build in the middle of building. + def on_build_cb(cls, artifact_name, source): + self.client.put('/module-build-service/1/module-builds/cancel/' + str(module_build_id)) + + cancelled_tasks = [] + def on_cancel_cb(cls, task_id): + cancelled_tasks.append(task_id) + + # We do not want the builds to COMPLETE, but instead we want them + # to be in the BULDING state after the TestModuleBuilder.build(). + TestModuleBuilder.BUILD_STATE = "BUILDING" + TestModuleBuilder.on_build_cb = on_build_cb + TestModuleBuilder.on_cancel_cb = on_cancel_cb + + msgs = [] + msgs.append(RidaModule("fake msg", 1, 1)) + module_build_service.scheduler.main.main(msgs, True) + + # Because we did not finished single component build and canceled the + # module build, all components and even the module itself should be in + # failed state with state_reason se to cancellation message. + for build in models.ComponentBuild.query.filter_by(module_id=module_build_id).all(): + self.assertEqual(build.state, koji.BUILD_STATES['FAILED']) + self.assertEqual(build.state_reason, "Canceled by Homer J. Simpson.") + self.assertEqual(build.module_build.state, models.BUILD_STATES["failed"]) + self.assertEqual(build.module_build.state_reason, "Canceled by Homer J. Simpson.") + + # Check that cancel_build has been called for this build + if build.task_id: + self.assertTrue(build.task_id in cancelled_tasks) diff --git a/tests/test_views/test_views.py b/tests/test_views/test_views.py index 631e8d88..f9fa0c90 100644 --- a/tests/test_views/test_views.py +++ b/tests/test_views/test_views.py @@ -401,4 +401,23 @@ class TestViews(unittest.TestCase): self.assertEquals(batches["bash"], 2) self.assertEquals(batches["file"], 3) + @patch('module_build_service.auth.get_username', return_value='some_other_user') + @patch('module_build_service.auth.assert_is_packager') + def test_cancel_build(self, mocked_assert_is_packager, + mocked_get_username): + rv = self.client.put('/module-build-service/1/module-builds/cancel/30') + data = json.loads(rv.data) + self.assertEquals(data['state'], 4) + self.assertEquals(data['state_reason'], 'Canceled by some_other_user.') + + + @patch('module_build_service.auth.get_username', return_value='Someone else') + @patch('module_build_service.auth.assert_is_packager') + def test_cancel_build_unauthorized(self, mocked_assert_is_packager, + mocked_get_username): + rv = self.client.put('/module-build-service/1/module-builds/cancel/30') + data = json.loads(rv.data) + + self.assertEquals(data['status'], 401) + self.assertEquals(data['error'], 'Unauthorized')