Colin Watson has proposed merging ~cjwatson/launchpad:ci-build-webhooks into launchpad:master.
Commit message: Add webhooks for CI builds Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/442596 This was mostly just cribbed from charm recipe build webhooks, with adjustments as needed for the different data model here. I expect we'll probably need to add some more fields to the payload once people start using this in practice, but this should be enough to get started. -- Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build-webhooks into launchpad:master.
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml index eaf4020..cf2e52e 100644 --- a/lib/lp/code/configure.zcml +++ b/lib/lp/code/configure.zcml @@ -1,4 +1,4 @@ -<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the +<!-- Copyright 2009-2023 Canonical Ltd. This software is licensed under the GNU Affero General Public License version 3 (see the file LICENSE). --> @@ -1297,6 +1297,14 @@ permission="launchpad.Admin" interface="lp.code.interfaces.cibuild.ICIBuildAdmin" /> </class> + <subscriber + for="lp.code.interfaces.cibuild.ICIBuild + lazr.lifecycle.interfaces.IObjectCreatedEvent" + handler="lp.code.subscribers.cibuild.ci_build_created" /> + <subscriber + for="lp.code.interfaces.cibuild.ICIBuild + lazr.lifecycle.interfaces.IObjectModifiedEvent" + handler="lp.code.subscribers.cibuild.ci_build_modified" /> <!-- CIBuildSet --> <lp:securedutility diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py index 9599b75..bac6181 100644 --- a/lib/lp/code/interfaces/cibuild.py +++ b/lib/lp/code/interfaces/cibuild.py @@ -1,9 +1,10 @@ -# Copyright 2022 Canonical Ltd. This software is licensed under the +# Copyright 2022-2023 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Interfaces for CI builds.""" __all__ = [ + "CI_WEBHOOKS_FEATURE_FLAG", "CannotFetchConfiguration", "CannotParseConfiguration", "CIBuildAlreadyRequested", @@ -41,6 +42,8 @@ from lp.code.interfaces.gitrepository import IGitRepository from lp.services.database.constants import DEFAULT from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries +CI_WEBHOOKS_FEATURE_FLAG = "ci.webhooks.enabled" + class MissingConfiguration(Exception): """The repository for this CI build does not have a .launchpad.yaml.""" diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py index ad5bbfb..e1b7450 100644 --- a/lib/lp/code/model/cibuild.py +++ b/lib/lp/code/model/cibuild.py @@ -1,4 +1,4 @@ -# Copyright 2022 Canonical Ltd. This software is licensed under the +# Copyright 2022-2023 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """CI builds.""" @@ -77,6 +77,7 @@ from lp.services.macaroons.interfaces import ( ) from lp.services.macaroons.model import MacaroonIssuerBase from lp.services.propertycache import cachedproperty +from lp.services.webapp.snapshot import notify_modified from lp.soyuz.model.binarypackagename import BinaryPackageName from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease from lp.soyuz.model.distroarchseries import DistroArchSeries @@ -504,6 +505,31 @@ class CIBuild(PackageBuildMixin, StormBase): # We have no interesting checks to perform here. return True + def updateStatus( + self, + status, + builder=None, + worker_status=None, + date_started=None, + date_finished=None, + force_invalid_transition=False, + ): + """See `IBuildFarmJob`.""" + edited_fields = set() + with notify_modified( + self, edited_fields, snapshot_names=("status",) + ) as previous_obj: + super().updateStatus( + status, + builder=builder, + worker_status=worker_status, + date_started=date_started, + date_finished=date_finished, + force_invalid_transition=force_invalid_transition, + ) + if self.status != previous_obj.status: + edited_fields.add("status") + def notify(self, extra_info=None): """See `IPackageBuild`.""" from lp.code.mail.cibuild import CIBuildMailer diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py index 0ffae31..0384d58 100644 --- a/lib/lp/code/model/gitrepository.py +++ b/lib/lp/code/model/gitrepository.py @@ -417,7 +417,7 @@ class GitRepository( @property def valid_webhook_event_types(self): - return ["git:push:0.1", "merge-proposal:0.1"] + return ["ci:build:0.1", "git:push:0.1", "merge-proposal:0.1"] @property def default_webhook_event_types(self): diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py index ec452d6..ae95fbd 100644 --- a/lib/lp/code/model/tests/test_cibuild.py +++ b/lib/lp/code/model/tests/test_cibuild.py @@ -1,4 +1,4 @@ -# Copyright 2022 Canonical Ltd. This software is licensed under the +# Copyright 2022-2023 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Test CI builds.""" @@ -9,13 +9,14 @@ from textwrap import dedent from unittest.mock import Mock from urllib.request import urlopen -from fixtures import MockPatchObject +from fixtures import FakeLogger, MockPatchObject from pymacaroons import Macaroon from storm.locals import Store from testtools.matchers import ( ContainsDict, Equals, Is, + MatchesDict, MatchesListwise, MatchesSetwise, MatchesStructure, @@ -35,6 +36,7 @@ from lp.buildmaster.model.buildfarmjob import BuildFarmJob from lp.buildmaster.model.buildqueue import BuildQueue from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault from lp.code.interfaces.cibuild import ( + CI_WEBHOOKS_FEATURE_FLAG, CannotFetchConfiguration, CannotParseConfiguration, CIBuildAlreadyRequested, @@ -55,12 +57,15 @@ from lp.registry.interfaces.sourcepackage import SourcePackageType from lp.services.authserver.xmlrpc import AuthServerAPIView from lp.services.config import config from lp.services.database.sqlbase import flush_database_caches +from lp.services.features.testing import FeatureFixture from lp.services.librarian.browser import ProxiedLibraryFileAlias from lp.services.log.logger import BufferLogger from lp.services.macaroons.interfaces import IMacaroonIssuer from lp.services.macaroons.testing import MacaroonTestMixin from lp.services.propertycache import clear_property_cache from lp.services.webapp.interfaces import OAuthPermission +from lp.services.webapp.publisher import canonical_url +from lp.services.webhooks.testing import LogsScheduledWebhooks from lp.soyuz.enums import BinaryPackageFormat from lp.testing import ( ANONYMOUS, @@ -73,6 +78,7 @@ from lp.testing import ( person_logged_in, pop_notifications, ) +from lp.testing.dbuser import dbuser from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer from lp.testing.matchers import HasQueryCount from lp.testing.pages import webservice_for_person @@ -320,6 +326,95 @@ class TestCIBuild(TestCaseWithFactory): build = self.factory.makeCIBuild() self.assertTrue(build.verifySuccessfulUpload()) + def test_updateStatus_triggers_webhooks(self): + # Updating the status of a CIBuild triggers webhooks on the + # corresponding GitRepository. + self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"})) + logger = self.useFixture(FakeLogger()) + build = self.factory.makeCIBuild() + hook = self.factory.makeWebhook( + target=build.git_repository, event_types=["ci:build:0.1"] + ) + build.updateStatus(BuildStatus.FULLYBUILT) + expected_payload = { + "build": Equals(canonical_url(build, force_local_path=True)), + "action": Equals("status-changed"), + "git_repository": Equals( + canonical_url(build.git_repository, force_local_path=True) + ), + "commit_sha1": Equals(build.commit_sha1), + "status": Equals("Successfully built"), + } + delivery = hook.deliveries.one() + self.assertThat( + delivery, + MatchesStructure( + event_type=Equals("ci:build:0.1"), + payload=MatchesDict(expected_payload), + ), + ) + with dbuser(config.IWebhookDeliveryJobSource.dbuser): + self.assertEqual( + "<WebhookDeliveryJob for webhook %d on %r>" + % (hook.id, hook.target), + repr(delivery), + ) + self.assertThat( + logger.output, + LogsScheduledWebhooks( + [(hook, "ci:build:0.1", MatchesDict(expected_payload))] + ), + ) + + def test_updateStatus_no_change_does_not_trigger_webhooks(self): + # An updateStatus call that changes details of the worker status but + # that doesn't change the build's status attribute does not trigger + # webhooks. + self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"})) + logger = self.useFixture(FakeLogger()) + build = self.factory.makeCIBuild() + hook = self.factory.makeWebhook( + target=build.git_repository, event_types=["ci:build:0.1"] + ) + builder = self.factory.makeBuilder() + build.updateStatus(BuildStatus.BUILDING) + expected_logs = [ + ( + hook, + "ci:build:0.1", + ContainsDict( + { + "action": Equals("status-changed"), + "status": Equals("Currently building"), + } + ), + ) + ] + self.assertEqual(1, hook.deliveries.count()) + self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs)) + build.updateStatus( + BuildStatus.BUILDING, + builder=builder, + worker_status={"revision_id": build.commit_sha1}, + ) + self.assertEqual(1, hook.deliveries.count()) + self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs)) + build.updateStatus(BuildStatus.UPLOADING) + expected_logs.append( + ( + hook, + "ci:build:0.1", + ContainsDict( + { + "action": Equals("status-changed"), + "status": Equals("Uploading build"), + } + ), + ) + ) + self.assertEqual(2, hook.deliveries.count()) + self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs)) + def addFakeBuildLog(self, build): build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt")) diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py index 2aeb2ca..ecf5663 100644 --- a/lib/lp/code/model/tests/test_gitrepository.py +++ b/lib/lp/code/model/tests/test_gitrepository.py @@ -12,7 +12,7 @@ from textwrap import dedent import transaction from breezy import urlutils -from fixtures import MockPatch +from fixtures import FakeLogger, MockPatch from lazr.lifecycle.event import ObjectModifiedEvent from pymacaroons import Macaroon from storm.exceptions import LostObjectError @@ -76,7 +76,11 @@ from lp.code.event.git import GitRefsUpdatedEvent from lp.code.interfaces.branchmergeproposal import ( BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES, ) -from lp.code.interfaces.cibuild import ICIBuild, ICIBuildSet +from lp.code.interfaces.cibuild import ( + CI_WEBHOOKS_FEATURE_FLAG, + ICIBuild, + ICIBuildSet, +) from lp.code.interfaces.codeimport import ICodeImportSet from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository from lp.code.interfaces.gitjob import ( @@ -170,7 +174,9 @@ from lp.services.propertycache import clear_property_cache from lp.services.utils import seconds_since_epoch from lp.services.webapp.authorization import check_permission from lp.services.webapp.interfaces import OAuthPermission +from lp.services.webapp.publisher import canonical_url from lp.services.webapp.snapshot import notify_modified +from lp.services.webhooks.testing import LogsScheduledWebhooks from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS from lp.testing import ( ANONYMOUS, @@ -4171,6 +4177,83 @@ class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory): ) self.assertEqual("", logger.getLogBuffer()) + def test_triggers_webhooks(self): + # Requesting CI builds triggers any relevant webhooks. + self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"})) + logger = self.useFixture(FakeLogger()) + repository = self.factory.makeGitRepository() + hook = self.factory.makeWebhook( + target=repository, event_types=["ci:build:0.1"] + ) + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + distroseries = self.factory.makeDistroSeries(distribution=ubuntu) + das = self.factory.makeBuildableDistroArchSeries( + distroseries=distroseries + ) + configuration = dedent( + """\ + pipeline: [test] + jobs: + test: + series: {series} + architectures: [{architecture}] + """.format( + series=distroseries.name, architecture=das.architecturetag + ) + ).encode() + new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + self.useFixture( + GitHostingFixture( + commits=[ + { + "sha1": new_commit, + "blobs": {".launchpad.yaml": configuration}, + }, + ] + ) + ) + with dbuser("branchscanner"): + repository.createOrUpdateRefs( + { + "refs/heads/test": { + "sha1": new_commit, + "type": GitObjectType.COMMIT, + } + } + ) + + [build] = getUtility(ICIBuildSet).findByGitRepository(repository) + delivery = hook.deliveries.one() + payload_matcher = MatchesDict( + { + "build": Equals(canonical_url(build, force_local_path=True)), + "action": Equals("created"), + "git_repository": Equals( + canonical_url(repository, force_local_path=True) + ), + "commit_sha1": Equals(new_commit), + "status": Equals("Needs building"), + } + ) + self.assertThat( + delivery, + MatchesStructure( + event_type=Equals("ci:build:0.1"), payload=payload_matcher + ), + ) + with dbuser(config.IWebhookDeliveryJobSource.dbuser): + self.assertEqual( + "<WebhookDeliveryJob for webhook %d on %r>" + % (hook.id, hook.target), + repr(delivery), + ) + self.assertThat( + logger.output, + LogsScheduledWebhooks( + [(hook, "ci:build:0.1", payload_matcher)] + ), + ) + class TestGitRepositoryGetBlob(TestCaseWithFactory): """Tests for retrieving files from a Git repository.""" diff --git a/lib/lp/code/subscribers/cibuild.py b/lib/lp/code/subscribers/cibuild.py new file mode 100644 index 0000000..a0eb3fc --- /dev/null +++ b/lib/lp/code/subscribers/cibuild.py @@ -0,0 +1,40 @@ +# Copyright 2023 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Event subscribers for CI builds.""" + +from zope.component import getUtility + +from lp.code.interfaces.cibuild import CI_WEBHOOKS_FEATURE_FLAG, ICIBuild +from lp.services.features import getFeatureFlag +from lp.services.webapp.publisher import canonical_url +from lp.services.webhooks.interfaces import IWebhookSet +from lp.services.webhooks.payload import compose_webhook_payload + + +def _trigger_ci_build_webhook(build, action): + if getFeatureFlag(CI_WEBHOOKS_FEATURE_FLAG): + payload = { + "build": canonical_url(build, force_local_path=True), + "action": action, + } + payload.update( + compose_webhook_payload( + ICIBuild, build, ["git_repository", "commit_sha1", "status"] + ) + ) + getUtility(IWebhookSet).trigger( + build.git_repository, "ci:build:0.1", payload + ) + + +def ci_build_created(build, event): + """Trigger events when a new CI build is created.""" + _trigger_ci_build_webhook(build, "created") + + +def ci_build_modified(build, event): + """Trigger events when a CI build is modified.""" + if event.edited_fields is not None: + if "status" in event.edited_fields: + _trigger_ci_build_webhook(build, "status-changed") diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py index b31f29d..4f0a1e8 100644 --- a/lib/lp/services/webhooks/interfaces.py +++ b/lib/lp/services/webhooks/interfaces.py @@ -1,4 +1,4 @@ -# Copyright 2015-2021 Canonical Ltd. This software is licensed under the +# Copyright 2015-2023 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Webhook interfaces.""" @@ -50,6 +50,7 @@ from lp.services.webservice.apihelpers import ( WEBHOOK_EVENT_TYPES = { "bzr:push:0.1": "Bazaar push", "charm-recipe:build:0.1": "Charm recipe build", + "ci:build:0.1": "CI build", "git:push:0.1": "Git push", "livefs:build:0.1": "Live filesystem build", "merge-proposal:0.1": "Merge proposal", diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py index 65ba0c3..b3f28fe 100644 --- a/lib/lp/services/webhooks/tests/test_browser.py +++ b/lib/lp/services/webhooks/tests/test_browser.py @@ -1,4 +1,4 @@ -# Copyright 2015-2021 Canonical Ltd. This software is licensed under the +# Copyright 2015-2023 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Unit tests for Webhook views.""" @@ -63,6 +63,7 @@ class GitRepositoryTestHelpers: event_type = "git:push:0.1" expected_event_types = [ + ("ci:build:0.1", "CI build"), ("git:push:0.1", "Git push"), ("merge-proposal:0.1", "Merge proposal"), ]
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp