Merge ~cjwatson/launchpad:ci-build-webhooks into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 9ab62fd3101a6c137c41e85e514a84cbbf0fda19
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:ci-build-webhooks
Merge into: launchpad:master
Diff against target: 483 lines (+269/-11)
10 files modified
lib/lp/code/configure.zcml (+9/-1)
lib/lp/code/interfaces/cibuild.py (+4/-1)
lib/lp/code/model/cibuild.py (+27/-1)
lib/lp/code/model/gitrepository.py (+1/-1)
lib/lp/code/model/tests/test_cibuild.py (+97/-2)
lib/lp/code/model/tests/test_gitrepository.py (+85/-2)
lib/lp/code/subscribers/cibuild.py (+41/-0)
lib/lp/services/webhooks/interfaces.py (+2/-1)
lib/lp/services/webhooks/tests/test_browser.py (+2/-1)
tox.ini (+1/-1)
Reviewer Review Type Date Requested Status
Ines Almeida Approve
Review via email: mp+442596@code.launchpad.net

Commit message

Add webhooks for CI builds

Description of the change

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.

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Looks good to me!

Was wondering why only trigger "status-changed" actions when the build is modified, but looking into other similar webhooks and thinking about the use case, that seems consistent.

review: Approve
Revision history for this message
Ines Almeida (ines-almeida) wrote :

One small note, we should update the webhooks documentation page

Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
2index eaf4020..cf2e52e 100644
3--- a/lib/lp/code/configure.zcml
4+++ b/lib/lp/code/configure.zcml
5@@ -1,4 +1,4 @@
6-<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the
7+<!-- Copyright 2009-2023 Canonical Ltd. This software is licensed under the
8 GNU Affero General Public License version 3 (see the file LICENSE).
9 -->
10
11@@ -1297,6 +1297,14 @@
12 permission="launchpad.Admin"
13 interface="lp.code.interfaces.cibuild.ICIBuildAdmin" />
14 </class>
15+ <subscriber
16+ for="lp.code.interfaces.cibuild.ICIBuild
17+ lazr.lifecycle.interfaces.IObjectCreatedEvent"
18+ handler="lp.code.subscribers.cibuild.ci_build_created" />
19+ <subscriber
20+ for="lp.code.interfaces.cibuild.ICIBuild
21+ lazr.lifecycle.interfaces.IObjectModifiedEvent"
22+ handler="lp.code.subscribers.cibuild.ci_build_modified" />
23
24 <!-- CIBuildSet -->
25 <lp:securedutility
26diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
27index 9599b75..bac6181 100644
28--- a/lib/lp/code/interfaces/cibuild.py
29+++ b/lib/lp/code/interfaces/cibuild.py
30@@ -1,9 +1,10 @@
31-# Copyright 2022 Canonical Ltd. This software is licensed under the
32+# Copyright 2022-2023 Canonical Ltd. This software is licensed under the
33 # GNU Affero General Public License version 3 (see the file LICENSE).
34
35 """Interfaces for CI builds."""
36
37 __all__ = [
38+ "CI_WEBHOOKS_FEATURE_FLAG",
39 "CannotFetchConfiguration",
40 "CannotParseConfiguration",
41 "CIBuildAlreadyRequested",
42@@ -41,6 +42,8 @@ from lp.code.interfaces.gitrepository import IGitRepository
43 from lp.services.database.constants import DEFAULT
44 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
45
46+CI_WEBHOOKS_FEATURE_FLAG = "ci.webhooks.enabled"
47+
48
49 class MissingConfiguration(Exception):
50 """The repository for this CI build does not have a .launchpad.yaml."""
51diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
52index ad5bbfb..e1b7450 100644
53--- a/lib/lp/code/model/cibuild.py
54+++ b/lib/lp/code/model/cibuild.py
55@@ -1,4 +1,4 @@
56-# Copyright 2022 Canonical Ltd. This software is licensed under the
57+# Copyright 2022-2023 Canonical Ltd. This software is licensed under the
58 # GNU Affero General Public License version 3 (see the file LICENSE).
59
60 """CI builds."""
61@@ -77,6 +77,7 @@ from lp.services.macaroons.interfaces import (
62 )
63 from lp.services.macaroons.model import MacaroonIssuerBase
64 from lp.services.propertycache import cachedproperty
65+from lp.services.webapp.snapshot import notify_modified
66 from lp.soyuz.model.binarypackagename import BinaryPackageName
67 from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease
68 from lp.soyuz.model.distroarchseries import DistroArchSeries
69@@ -504,6 +505,31 @@ class CIBuild(PackageBuildMixin, StormBase):
70 # We have no interesting checks to perform here.
71 return True
72
73+ def updateStatus(
74+ self,
75+ status,
76+ builder=None,
77+ worker_status=None,
78+ date_started=None,
79+ date_finished=None,
80+ force_invalid_transition=False,
81+ ):
82+ """See `IBuildFarmJob`."""
83+ edited_fields = set()
84+ with notify_modified(
85+ self, edited_fields, snapshot_names=("status",)
86+ ) as previous_obj:
87+ super().updateStatus(
88+ status,
89+ builder=builder,
90+ worker_status=worker_status,
91+ date_started=date_started,
92+ date_finished=date_finished,
93+ force_invalid_transition=force_invalid_transition,
94+ )
95+ if self.status != previous_obj.status:
96+ edited_fields.add("status")
97+
98 def notify(self, extra_info=None):
99 """See `IPackageBuild`."""
100 from lp.code.mail.cibuild import CIBuildMailer
101diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
102index 0ffae31..0384d58 100644
103--- a/lib/lp/code/model/gitrepository.py
104+++ b/lib/lp/code/model/gitrepository.py
105@@ -417,7 +417,7 @@ class GitRepository(
106
107 @property
108 def valid_webhook_event_types(self):
109- return ["git:push:0.1", "merge-proposal:0.1"]
110+ return ["ci:build:0.1", "git:push:0.1", "merge-proposal:0.1"]
111
112 @property
113 def default_webhook_event_types(self):
114diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
115index ec452d6..ae95fbd 100644
116--- a/lib/lp/code/model/tests/test_cibuild.py
117+++ b/lib/lp/code/model/tests/test_cibuild.py
118@@ -1,4 +1,4 @@
119-# Copyright 2022 Canonical Ltd. This software is licensed under the
120+# Copyright 2022-2023 Canonical Ltd. This software is licensed under the
121 # GNU Affero General Public License version 3 (see the file LICENSE).
122
123 """Test CI builds."""
124@@ -9,13 +9,14 @@ from textwrap import dedent
125 from unittest.mock import Mock
126 from urllib.request import urlopen
127
128-from fixtures import MockPatchObject
129+from fixtures import FakeLogger, MockPatchObject
130 from pymacaroons import Macaroon
131 from storm.locals import Store
132 from testtools.matchers import (
133 ContainsDict,
134 Equals,
135 Is,
136+ MatchesDict,
137 MatchesListwise,
138 MatchesSetwise,
139 MatchesStructure,
140@@ -35,6 +36,7 @@ from lp.buildmaster.model.buildfarmjob import BuildFarmJob
141 from lp.buildmaster.model.buildqueue import BuildQueue
142 from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault
143 from lp.code.interfaces.cibuild import (
144+ CI_WEBHOOKS_FEATURE_FLAG,
145 CannotFetchConfiguration,
146 CannotParseConfiguration,
147 CIBuildAlreadyRequested,
148@@ -55,12 +57,15 @@ from lp.registry.interfaces.sourcepackage import SourcePackageType
149 from lp.services.authserver.xmlrpc import AuthServerAPIView
150 from lp.services.config import config
151 from lp.services.database.sqlbase import flush_database_caches
152+from lp.services.features.testing import FeatureFixture
153 from lp.services.librarian.browser import ProxiedLibraryFileAlias
154 from lp.services.log.logger import BufferLogger
155 from lp.services.macaroons.interfaces import IMacaroonIssuer
156 from lp.services.macaroons.testing import MacaroonTestMixin
157 from lp.services.propertycache import clear_property_cache
158 from lp.services.webapp.interfaces import OAuthPermission
159+from lp.services.webapp.publisher import canonical_url
160+from lp.services.webhooks.testing import LogsScheduledWebhooks
161 from lp.soyuz.enums import BinaryPackageFormat
162 from lp.testing import (
163 ANONYMOUS,
164@@ -73,6 +78,7 @@ from lp.testing import (
165 person_logged_in,
166 pop_notifications,
167 )
168+from lp.testing.dbuser import dbuser
169 from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
170 from lp.testing.matchers import HasQueryCount
171 from lp.testing.pages import webservice_for_person
172@@ -320,6 +326,95 @@ class TestCIBuild(TestCaseWithFactory):
173 build = self.factory.makeCIBuild()
174 self.assertTrue(build.verifySuccessfulUpload())
175
176+ def test_updateStatus_triggers_webhooks(self):
177+ # Updating the status of a CIBuild triggers webhooks on the
178+ # corresponding GitRepository.
179+ self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
180+ logger = self.useFixture(FakeLogger())
181+ build = self.factory.makeCIBuild()
182+ hook = self.factory.makeWebhook(
183+ target=build.git_repository, event_types=["ci:build:0.1"]
184+ )
185+ build.updateStatus(BuildStatus.FULLYBUILT)
186+ expected_payload = {
187+ "build": Equals(canonical_url(build, force_local_path=True)),
188+ "action": Equals("status-changed"),
189+ "git_repository": Equals(
190+ canonical_url(build.git_repository, force_local_path=True)
191+ ),
192+ "commit_sha1": Equals(build.commit_sha1),
193+ "status": Equals("Successfully built"),
194+ }
195+ delivery = hook.deliveries.one()
196+ self.assertThat(
197+ delivery,
198+ MatchesStructure(
199+ event_type=Equals("ci:build:0.1"),
200+ payload=MatchesDict(expected_payload),
201+ ),
202+ )
203+ with dbuser(config.IWebhookDeliveryJobSource.dbuser):
204+ self.assertEqual(
205+ "<WebhookDeliveryJob for webhook %d on %r>"
206+ % (hook.id, hook.target),
207+ repr(delivery),
208+ )
209+ self.assertThat(
210+ logger.output,
211+ LogsScheduledWebhooks(
212+ [(hook, "ci:build:0.1", MatchesDict(expected_payload))]
213+ ),
214+ )
215+
216+ def test_updateStatus_no_change_does_not_trigger_webhooks(self):
217+ # An updateStatus call that changes details of the worker status but
218+ # that doesn't change the build's status attribute does not trigger
219+ # webhooks.
220+ self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
221+ logger = self.useFixture(FakeLogger())
222+ build = self.factory.makeCIBuild()
223+ hook = self.factory.makeWebhook(
224+ target=build.git_repository, event_types=["ci:build:0.1"]
225+ )
226+ builder = self.factory.makeBuilder()
227+ build.updateStatus(BuildStatus.BUILDING)
228+ expected_logs = [
229+ (
230+ hook,
231+ "ci:build:0.1",
232+ ContainsDict(
233+ {
234+ "action": Equals("status-changed"),
235+ "status": Equals("Currently building"),
236+ }
237+ ),
238+ )
239+ ]
240+ self.assertEqual(1, hook.deliveries.count())
241+ self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
242+ build.updateStatus(
243+ BuildStatus.BUILDING,
244+ builder=builder,
245+ worker_status={"revision_id": build.commit_sha1},
246+ )
247+ self.assertEqual(1, hook.deliveries.count())
248+ self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
249+ build.updateStatus(BuildStatus.UPLOADING)
250+ expected_logs.append(
251+ (
252+ hook,
253+ "ci:build:0.1",
254+ ContainsDict(
255+ {
256+ "action": Equals("status-changed"),
257+ "status": Equals("Uploading build"),
258+ }
259+ ),
260+ )
261+ )
262+ self.assertEqual(2, hook.deliveries.count())
263+ self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
264+
265 def addFakeBuildLog(self, build):
266 build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
267
268diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
269index 2aeb2ca..ecf5663 100644
270--- a/lib/lp/code/model/tests/test_gitrepository.py
271+++ b/lib/lp/code/model/tests/test_gitrepository.py
272@@ -12,7 +12,7 @@ from textwrap import dedent
273
274 import transaction
275 from breezy import urlutils
276-from fixtures import MockPatch
277+from fixtures import FakeLogger, MockPatch
278 from lazr.lifecycle.event import ObjectModifiedEvent
279 from pymacaroons import Macaroon
280 from storm.exceptions import LostObjectError
281@@ -76,7 +76,11 @@ from lp.code.event.git import GitRefsUpdatedEvent
282 from lp.code.interfaces.branchmergeproposal import (
283 BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES,
284 )
285-from lp.code.interfaces.cibuild import ICIBuild, ICIBuildSet
286+from lp.code.interfaces.cibuild import (
287+ CI_WEBHOOKS_FEATURE_FLAG,
288+ ICIBuild,
289+ ICIBuildSet,
290+)
291 from lp.code.interfaces.codeimport import ICodeImportSet
292 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
293 from lp.code.interfaces.gitjob import (
294@@ -170,7 +174,9 @@ from lp.services.propertycache import clear_property_cache
295 from lp.services.utils import seconds_since_epoch
296 from lp.services.webapp.authorization import check_permission
297 from lp.services.webapp.interfaces import OAuthPermission
298+from lp.services.webapp.publisher import canonical_url
299 from lp.services.webapp.snapshot import notify_modified
300+from lp.services.webhooks.testing import LogsScheduledWebhooks
301 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
302 from lp.testing import (
303 ANONYMOUS,
304@@ -4171,6 +4177,83 @@ class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory):
305 )
306 self.assertEqual("", logger.getLogBuffer())
307
308+ def test_triggers_webhooks(self):
309+ # Requesting CI builds triggers any relevant webhooks.
310+ self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
311+ logger = self.useFixture(FakeLogger())
312+ repository = self.factory.makeGitRepository()
313+ hook = self.factory.makeWebhook(
314+ target=repository, event_types=["ci:build:0.1"]
315+ )
316+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
317+ distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
318+ das = self.factory.makeBuildableDistroArchSeries(
319+ distroseries=distroseries
320+ )
321+ configuration = dedent(
322+ """\
323+ pipeline: [test]
324+ jobs:
325+ test:
326+ series: {series}
327+ architectures: [{architecture}]
328+ """.format(
329+ series=distroseries.name, architecture=das.architecturetag
330+ )
331+ ).encode()
332+ new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
333+ self.useFixture(
334+ GitHostingFixture(
335+ commits=[
336+ {
337+ "sha1": new_commit,
338+ "blobs": {".launchpad.yaml": configuration},
339+ },
340+ ]
341+ )
342+ )
343+ with dbuser("branchscanner"):
344+ repository.createOrUpdateRefs(
345+ {
346+ "refs/heads/test": {
347+ "sha1": new_commit,
348+ "type": GitObjectType.COMMIT,
349+ }
350+ }
351+ )
352+
353+ [build] = getUtility(ICIBuildSet).findByGitRepository(repository)
354+ delivery = hook.deliveries.one()
355+ payload_matcher = MatchesDict(
356+ {
357+ "build": Equals(canonical_url(build, force_local_path=True)),
358+ "action": Equals("created"),
359+ "git_repository": Equals(
360+ canonical_url(repository, force_local_path=True)
361+ ),
362+ "commit_sha1": Equals(new_commit),
363+ "status": Equals("Needs building"),
364+ }
365+ )
366+ self.assertThat(
367+ delivery,
368+ MatchesStructure(
369+ event_type=Equals("ci:build:0.1"), payload=payload_matcher
370+ ),
371+ )
372+ with dbuser(config.IWebhookDeliveryJobSource.dbuser):
373+ self.assertEqual(
374+ "<WebhookDeliveryJob for webhook %d on %r>"
375+ % (hook.id, hook.target),
376+ repr(delivery),
377+ )
378+ self.assertThat(
379+ logger.output,
380+ LogsScheduledWebhooks(
381+ [(hook, "ci:build:0.1", payload_matcher)]
382+ ),
383+ )
384+
385
386 class TestGitRepositoryGetBlob(TestCaseWithFactory):
387 """Tests for retrieving files from a Git repository."""
388diff --git a/lib/lp/code/subscribers/cibuild.py b/lib/lp/code/subscribers/cibuild.py
389new file mode 100644
390index 0000000..83d3363
391--- /dev/null
392+++ b/lib/lp/code/subscribers/cibuild.py
393@@ -0,0 +1,41 @@
394+# Copyright 2023 Canonical Ltd. This software is licensed under the
395+# GNU Affero General Public License version 3 (see the file LICENSE).
396+
397+"""Event subscribers for CI builds."""
398+
399+from lazr.lifecycle.interfaces import IObjectCreatedEvent, IObjectModifiedEvent
400+from zope.component import getUtility
401+
402+from lp.code.interfaces.cibuild import CI_WEBHOOKS_FEATURE_FLAG, ICIBuild
403+from lp.services.features import getFeatureFlag
404+from lp.services.webapp.publisher import canonical_url
405+from lp.services.webhooks.interfaces import IWebhookSet
406+from lp.services.webhooks.payload import compose_webhook_payload
407+
408+
409+def _trigger_ci_build_webhook(build: ICIBuild, action: str) -> None:
410+ if getFeatureFlag(CI_WEBHOOKS_FEATURE_FLAG):
411+ payload = {
412+ "build": canonical_url(build, force_local_path=True),
413+ "action": action,
414+ }
415+ payload.update(
416+ compose_webhook_payload(
417+ ICIBuild, build, ["git_repository", "commit_sha1", "status"]
418+ )
419+ )
420+ getUtility(IWebhookSet).trigger(
421+ build.git_repository, "ci:build:0.1", payload
422+ )
423+
424+
425+def ci_build_created(build: ICIBuild, event: IObjectCreatedEvent) -> None:
426+ """Trigger events when a new CI build is created."""
427+ _trigger_ci_build_webhook(build, "created")
428+
429+
430+def ci_build_modified(build: ICIBuild, event: IObjectModifiedEvent) -> None:
431+ """Trigger events when a CI build is modified."""
432+ if event.edited_fields is not None:
433+ if "status" in event.edited_fields:
434+ _trigger_ci_build_webhook(build, "status-changed")
435diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
436index b31f29d..4f0a1e8 100644
437--- a/lib/lp/services/webhooks/interfaces.py
438+++ b/lib/lp/services/webhooks/interfaces.py
439@@ -1,4 +1,4 @@
440-# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
441+# Copyright 2015-2023 Canonical Ltd. This software is licensed under the
442 # GNU Affero General Public License version 3 (see the file LICENSE).
443
444 """Webhook interfaces."""
445@@ -50,6 +50,7 @@ from lp.services.webservice.apihelpers import (
446 WEBHOOK_EVENT_TYPES = {
447 "bzr:push:0.1": "Bazaar push",
448 "charm-recipe:build:0.1": "Charm recipe build",
449+ "ci:build:0.1": "CI build",
450 "git:push:0.1": "Git push",
451 "livefs:build:0.1": "Live filesystem build",
452 "merge-proposal:0.1": "Merge proposal",
453diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py
454index 65ba0c3..b3f28fe 100644
455--- a/lib/lp/services/webhooks/tests/test_browser.py
456+++ b/lib/lp/services/webhooks/tests/test_browser.py
457@@ -1,4 +1,4 @@
458-# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
459+# Copyright 2015-2023 Canonical Ltd. This software is licensed under the
460 # GNU Affero General Public License version 3 (see the file LICENSE).
461
462 """Unit tests for Webhook views."""
463@@ -63,6 +63,7 @@ class GitRepositoryTestHelpers:
464
465 event_type = "git:push:0.1"
466 expected_event_types = [
467+ ("ci:build:0.1", "CI build"),
468 ("git:push:0.1", "Git push"),
469 ("merge-proposal:0.1", "Merge proposal"),
470 ]
471diff --git a/tox.ini b/tox.ini
472index 9054ea2..dbf21ce 100644
473--- a/tox.ini
474+++ b/tox.ini
475@@ -27,7 +27,7 @@ commands_pre =
476 {toxinidir}/scripts/update-version-info.sh
477 commands =
478 mypy --follow-imports=silent \
479- {posargs:lib/lp/answers lib/lp/app lib/lp/archivepublisher lib/lp/archiveuploader lib/lp/buildmaster lib/lp/charms/model/charmrecipebuildbehaviour.py lib/lp/code/model/cibuildbehaviour.py lib/lp/code/model/recipebuilder.py lib/lp/oci/model/ocirecipebuildbehaviour.py lib/lp/snappy/model/snapbuildbehaviour.py lib/lp/soyuz/model/binarypackagebuildbehaviour.py lib/lp/soyuz/model/livefsbuildbehaviour.py lib/lp/testing lib/lp/translations/model/translationtemplatesbuildbehaviour.py}
480+ {posargs:lib/lp/answers lib/lp/app lib/lp/archivepublisher lib/lp/archiveuploader lib/lp/buildmaster lib/lp/charms/model/charmrecipebuildbehaviour.py lib/lp/code/model/cibuildbehaviour.py lib/lp/code/model/recipebuilder.py lib/lp/code/subscribers lib/lp/oci/model/ocirecipebuildbehaviour.py lib/lp/snappy/model/snapbuildbehaviour.py lib/lp/soyuz/model/binarypackagebuildbehaviour.py lib/lp/soyuz/model/livefsbuildbehaviour.py lib/lp/testing lib/lp/translations/model/translationtemplatesbuildbehaviour.py}
481
482 [testenv:docs]
483 basepython = python3

Subscribers

People subscribed via source and target branches

to status/vote changes: