Merge lp:~cjwatson/launchpad/snap-build-created-webhook into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18771
Proposed branch: lp:~cjwatson/launchpad/snap-build-created-webhook
Merge into: lp:launchpad
Diff against target: 498 lines (+152/-30)
12 files modified
lib/lp/snappy/configure.zcml (+4/-0)
lib/lp/snappy/interfaces/snap.py (+6/-2)
lib/lp/snappy/interfaces/snapbuild.py (+9/-1)
lib/lp/snappy/interfaces/snapjob.py (+8/-1)
lib/lp/snappy/model/snap.py (+7/-4)
lib/lp/snappy/model/snapbuild.py (+13/-3)
lib/lp/snappy/model/snapjob.py (+6/-1)
lib/lp/snappy/subscribers/snapbuild.py (+12/-6)
lib/lp/snappy/tests/test_snap.py (+73/-4)
lib/lp/snappy/tests/test_snapbuild.py (+3/-0)
lib/lp/snappy/tests/test_snapbuildjob.py (+3/-1)
lib/lp/snappy/tests/test_snapjob.py (+8/-7)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-build-created-webhook
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+352306@code.launchpad.net

Commit message

Issue snap:build:0.1 webhook delivery when creating a snap build, and include a link to the build request in the payload.

Description of the change

I need this as part of converting build.snapcraft.io over to the new snap.requestBuilds interface; it needs to be able to issue a build request, keep a note of the reason it did so, and then retrieve that when it receives a webhook delivery indicating that the build has been created. (The alternatives are to have BSI's request-builds endpoint poll until the builds are created, which is obviously horrible, or to lose the build annotations.)

This requires https://code.launchpad.net/~cjwatson/launchpad/db-snap-build-build-request/+merge/352303.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/snappy/configure.zcml'
2--- lib/lp/snappy/configure.zcml 2018-06-15 13:21:14 +0000
3+++ lib/lp/snappy/configure.zcml 2018-08-03 14:22:02 +0000
4@@ -63,6 +63,10 @@
5 </class>
6 <subscriber
7 for="lp.snappy.interfaces.snapbuild.ISnapBuild
8+ lazr.lifecycle.interfaces.IObjectCreatedEvent"
9+ handler="lp.snappy.subscribers.snapbuild.snap_build_created" />
10+ <subscriber
11+ for="lp.snappy.interfaces.snapbuild.ISnapBuild
12 lp.snappy.interfaces.snapbuild.ISnapBuildStatusChangedEvent"
13 handler="lp.snappy.subscribers.snapbuild.snap_build_status_changed" />
14
15
16=== modified file 'lib/lp/snappy/interfaces/snap.py'
17--- lib/lp/snappy/interfaces/snap.py 2018-06-18 22:08:58 +0000
18+++ lib/lp/snappy/interfaces/snap.py 2018-08-03 14:22:02 +0000
19@@ -365,7 +365,7 @@
20 @export_factory_operation(Interface, [])
21 @operation_for_version("devel")
22 def requestBuild(requester, archive, distro_arch_series, pocket,
23- channels=None):
24+ channels=None, build_request=None):
25 """Request that the snap package be built.
26
27 :param requester: The person requesting the build.
28@@ -374,6 +374,8 @@
29 :param pocket: The pocket that should be targeted.
30 :param channels: A dictionary mapping snap names to channels to use
31 for this build.
32+ :param build_request: The `ISnapBuildRequest` job being processed,
33+ if any.
34 :return: `ISnapBuild`.
35 """
36
37@@ -407,7 +409,7 @@
38
39 def requestBuildsFromJob(requester, archive, pocket, channels=None,
40 allow_failures=False, fetch_snapcraft_yaml=True,
41- logger=None):
42+ build_request=None, logger=None):
43 """Synchronous part of `Snap.requestBuilds`.
44
45 Request that the snap package be built for relevant architectures.
46@@ -424,6 +426,8 @@
47 appropriate branch or repository and use it to decide which
48 builds to request; if False, fall back to building for all
49 supported architectures.
50+ :param build_request: The `ISnapBuildRequest` job being processed,
51+ if any.
52 :param logger: An optional logger.
53 :return: A sequence of `ISnapBuild` instances.
54 """
55
56=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
57--- lib/lp/snappy/interfaces/snapbuild.py 2018-03-22 16:48:16 +0000
58+++ lib/lp/snappy/interfaces/snapbuild.py 2018-08-03 14:22:02 +0000
59@@ -52,7 +52,10 @@
60 from lp.registry.interfaces.pocket import PackagePublishingPocket
61 from lp.services.database.constants import DEFAULT
62 from lp.services.librarian.interfaces import ILibraryFileAlias
63-from lp.snappy.interfaces.snap import ISnap
64+from lp.snappy.interfaces.snap import (
65+ ISnap,
66+ ISnapBuildRequest,
67+ )
68 from lp.soyuz.interfaces.archive import IArchive
69 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
70
71@@ -122,6 +125,11 @@
72 class ISnapBuildView(IPackageBuild):
73 """`ISnapBuild` attributes that require launchpad.View permission."""
74
75+ build_request = Reference(
76+ ISnapBuildRequest,
77+ title=_("The build request that caused this build to be created."),
78+ required=False, readonly=True)
79+
80 requester = exported(Reference(
81 IPerson,
82 title=_("The person who requested this build."),
83
84=== modified file 'lib/lp/snappy/interfaces/snapjob.py'
85--- lib/lp/snappy/interfaces/snapjob.py 2018-06-15 13:21:14 +0000
86+++ lib/lp/snappy/interfaces/snapjob.py 2018-08-03 14:22:02 +0000
87@@ -32,7 +32,10 @@
88 IJobSource,
89 IRunnableJob,
90 )
91-from lp.snappy.interfaces.snap import ISnap
92+from lp.snappy.interfaces.snap import (
93+ ISnap,
94+ ISnapBuildRequest,
95+ )
96 from lp.snappy.interfaces.snapbuild import ISnapBuild
97 from lp.soyuz.interfaces.archive import IArchive
98
99@@ -78,6 +81,10 @@
100 title=_("Error message resulting from running this job."),
101 required=False, readonly=True)
102
103+ build_request = Reference(
104+ title=_("The build request corresponding to this job."),
105+ schema=ISnapBuildRequest, required=True, readonly=True)
106+
107 builds = List(
108 title=_("The builds created by this request."),
109 value_type=Reference(schema=ISnapBuild), required=True, readonly=True)
110
111=== modified file 'lib/lp/snappy/model/snap.py'
112--- lib/lp/snappy/model/snap.py 2018-07-30 09:07:30 +0000
113+++ lib/lp/snappy/model/snap.py 2018-08-03 14:22:02 +0000
114@@ -14,6 +14,7 @@
115 from operator import attrgetter
116 from urlparse import urlsplit
117
118+from lazr.lifecycle.event import ObjectCreatedEvent
119 from pymacaroons import Macaroon
120 import pytz
121 from storm.expr import (
122@@ -39,6 +40,7 @@
123 getAdapter,
124 getUtility,
125 )
126+from zope.event import notify
127 from zope.interface import implementer
128 from zope.security.interfaces import Unauthorized
129 from zope.security.proxy import removeSecurityProxy
130@@ -521,7 +523,7 @@
131 raise SnapBuildArchiveOwnerMismatch()
132
133 def requestBuild(self, requester, archive, distro_arch_series, pocket,
134- channels=None):
135+ channels=None, build_request=None):
136 """See `ISnap`."""
137 self._checkRequestBuild(requester, archive)
138 if distro_arch_series not in self.getAllowedArchitectures():
139@@ -540,8 +542,9 @@
140
141 build = getUtility(ISnapBuildSet).new(
142 requester, self, archive, distro_arch_series, pocket,
143- channels=channels)
144+ channels=channels, build_request=build_request)
145 build.queueBuild()
146+ notify(ObjectCreatedEvent(build, user=requester))
147 return build
148
149 def requestBuilds(self, requester, archive, pocket, channels=None):
150@@ -553,7 +556,7 @@
151
152 def requestBuildsFromJob(self, requester, archive, pocket, channels=None,
153 allow_failures=False, fetch_snapcraft_yaml=True,
154- logger=None):
155+ build_request=None, logger=None):
156 """See `ISnap`."""
157 if fetch_snapcraft_yaml:
158 try:
159@@ -585,7 +588,7 @@
160 try:
161 build = self.requestBuild(
162 requester, archive, supported_arches[arch], pocket,
163- channels)
164+ channels, build_request=build_request)
165 if logger is not None:
166 logger.debug(
167 " - %s/%s/%s: Build requested.",
168
169=== modified file 'lib/lp/snappy/model/snapbuild.py'
170--- lib/lp/snappy/model/snapbuild.py 2018-03-22 16:48:16 +0000
171+++ lib/lp/snappy/model/snapbuild.py 2018-08-03 14:22:02 +0000
172@@ -128,6 +128,8 @@
173 build_farm_job_id = Int(name='build_farm_job', allow_none=False)
174 build_farm_job = Reference(build_farm_job_id, 'BuildFarmJob.id')
175
176+ build_request_id = Int(name='build_request', allow_none=True)
177+
178 requester_id = Int(name='requester', allow_none=False)
179 requester = Reference(requester_id, 'Person.id')
180
181@@ -175,7 +177,7 @@
182
183 def __init__(self, build_farm_job, requester, snap, archive,
184 distro_arch_series, pocket, channels, processor, virtualized,
185- date_created):
186+ date_created, build_request=None):
187 """Construct a `SnapBuild`."""
188 super(SnapBuild, self).__init__()
189 self.build_farm_job = build_farm_job
190@@ -188,9 +190,17 @@
191 self.processor = processor
192 self.virtualized = virtualized
193 self.date_created = date_created
194+ if build_request is not None:
195+ self.build_request_id = build_request.id
196 self.status = BuildStatus.NEEDSBUILD
197
198 @property
199+ def build_request(self):
200+ """See `ISnapBuild`."""
201+ if self.build_request_id is not None:
202+ return self.snap.getBuildRequest(self.build_request_id)
203+
204+ @property
205 def is_private(self):
206 """See `IBuildFarmJob`."""
207 return (
208@@ -488,7 +498,7 @@
209 class SnapBuildSet(SpecificBuildFarmJobSourceMixin):
210
211 def new(self, requester, snap, archive, distro_arch_series, pocket,
212- channels=None, date_created=DEFAULT):
213+ channels=None, date_created=DEFAULT, build_request=None):
214 """See `ISnapBuildSet`."""
215 store = IMasterStore(SnapBuild)
216 build_farm_job = getUtility(IBuildFarmJobSource).new(
217@@ -499,7 +509,7 @@
218 pocket, channels, distro_arch_series.processor,
219 not distro_arch_series.processor.supports_nonvirtualized
220 or snap.require_virtualized or archive.require_virtualized,
221- date_created)
222+ date_created, build_request=build_request)
223 store.add(snapbuild)
224 return snapbuild
225
226
227=== modified file 'lib/lp/snappy/model/snapjob.py'
228--- lib/lp/snappy/model/snapjob.py 2018-06-15 13:21:14 +0000
229+++ lib/lp/snappy/model/snapjob.py 2018-08-03 14:22:02 +0000
230@@ -243,6 +243,11 @@
231 self.metadata["error_message"] = message
232
233 @property
234+ def build_request(self):
235+ """See `ISnapRequestBuildsJob`."""
236+ return self.snap.getBuildRequest(self.job.id)
237+
238+ @property
239 def builds(self):
240 """See `ISnapRequestBuildsJob`."""
241 build_ids = self.metadata.get("builds")
242@@ -272,7 +277,7 @@
243 try:
244 self.builds = self.snap.requestBuildsFromJob(
245 requester, archive, self.pocket, channels=self.channels,
246- logger=log)
247+ build_request=self.build_request, logger=log)
248 self.error_message = None
249 except self.retry_error_types:
250 raise
251
252=== modified file 'lib/lp/snappy/subscribers/snapbuild.py'
253--- lib/lp/snappy/subscribers/snapbuild.py 2017-03-20 00:03:52 +0000
254+++ lib/lp/snappy/subscribers/snapbuild.py 2018-08-03 14:22:02 +0000
255@@ -1,4 +1,4 @@
256-# Copyright 2016-2017 Canonical Ltd. This software is licensed under the
257+# Copyright 2016-2018 Canonical Ltd. This software is licensed under the
258 # GNU Affero General Public License version 3 (see the file LICENSE).
259
260 """Event subscribers for snap builds."""
261@@ -19,21 +19,27 @@
262 from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
263
264
265-def _trigger_snap_build_webhook(snapbuild):
266+def _trigger_snap_build_webhook(snapbuild, action):
267 if getFeatureFlag(SNAP_WEBHOOKS_FEATURE_FLAG):
268 payload = {
269 "snap_build": canonical_url(snapbuild, force_local_path=True),
270- "action": "status-changed",
271+ "action": action,
272 }
273 payload.update(compose_webhook_payload(
274- ISnapBuild, snapbuild, ["snap", "status", "store_upload_status"]))
275+ ISnapBuild, snapbuild,
276+ ["snap", "build_request", "status", "store_upload_status"]))
277 getUtility(IWebhookSet).trigger(
278 snapbuild.snap, "snap:build:0.1", payload)
279
280
281+def snap_build_created(snapbuild, event):
282+ """Trigger events when a new snap package build is created."""
283+ _trigger_snap_build_webhook(snapbuild, "created")
284+
285+
286 def snap_build_status_changed(snapbuild, event):
287 """Trigger events when snap package build statuses change."""
288- _trigger_snap_build_webhook(snapbuild)
289+ _trigger_snap_build_webhook(snapbuild, "status-changed")
290
291 if (snapbuild.snap.can_upload_to_store and snapbuild.snap.store_upload and
292 snapbuild.status == BuildStatus.FULLYBUILT):
293@@ -42,4 +48,4 @@
294
295 def snap_build_store_upload_status_changed(snapbuild, event):
296 """Trigger events when snap package build store upload statuses change."""
297- _trigger_snap_build_webhook(snapbuild)
298+ _trigger_snap_build_webhook(snapbuild, "status-changed")
299
300=== modified file 'lib/lp/snappy/tests/test_snap.py'
301--- lib/lp/snappy/tests/test_snap.py 2018-07-30 09:07:30 +0000
302+++ lib/lp/snappy/tests/test_snap.py 2018-08-03 14:22:02 +0000
303@@ -76,6 +76,7 @@
304 from lp.services.propertycache import clear_property_cache
305 from lp.services.timeout import default_timeout
306 from lp.services.webapp.interfaces import OAuthPermission
307+from lp.services.webapp.publisher import canonical_url
308 from lp.snappy.interfaces.snap import (
309 BadSnapSearchContext,
310 CannotFetchSnapcraftYaml,
311@@ -368,6 +369,38 @@
312 snap.owner, snap.distro_series.main_archive, distroarchseries,
313 PackagePublishingPocket.UPDATES)
314
315+ def test_requestBuild_triggers_webhooks(self):
316+ # Requesting a build triggers webhooks.
317+ processor = self.factory.makeProcessor(supports_virtualized=True)
318+ distroarchseries = self.makeBuildableDistroArchSeries(
319+ processor=processor)
320+ snap = self.factory.makeSnap(
321+ distroseries=distroarchseries.distroseries, processors=[processor])
322+ hook = self.factory.makeWebhook(
323+ target=snap, event_types=["snap:build:0.1"])
324+ build = snap.requestBuild(
325+ snap.owner, snap.distro_series.main_archive, distroarchseries,
326+ PackagePublishingPocket.UPDATES)
327+ expected_payload = {
328+ "snap_build": Equals(canonical_url(build, force_local_path=True)),
329+ "action": Equals("created"),
330+ "snap": Equals(canonical_url(snap, force_local_path=True)),
331+ "build_request": Is(None),
332+ "status": Equals("Needs building"),
333+ "store_upload_status": Equals("Unscheduled"),
334+ }
335+ with person_logged_in(snap.owner):
336+ delivery = hook.deliveries.one()
337+ self.assertThat(
338+ delivery, MatchesStructure(
339+ event_type=Equals("snap:build:0.1"),
340+ payload=MatchesDict(expected_payload)))
341+ with dbuser(config.IWebhookDeliveryJobSource.dbuser):
342+ self.assertEqual(
343+ "<WebhookDeliveryJob for webhook %d on %r>" % (
344+ hook.id, hook.target),
345+ repr(delivery))
346+
347 def test_requestBuilds(self):
348 # requestBuilds schedules a job and returns a corresponding
349 # SnapBuildRequest.
350@@ -437,7 +470,8 @@
351 with person_logged_in(job.requester):
352 builds = job.snap.requestBuildsFromJob(
353 job.requester, job.archive, job.pocket,
354- removeSecurityProxy(job.channels))
355+ removeSecurityProxy(job.channels),
356+ build_request=job.build_request)
357 self.assertRequestedBuildsMatch(builds, job, ["sparc"])
358
359 def test_requestBuildsFromJob_no_explicit_architectures(self):
360@@ -449,10 +483,11 @@
361 with person_logged_in(job.requester):
362 builds = job.snap.requestBuildsFromJob(
363 job.requester, job.archive, job.pocket,
364- removeSecurityProxy(job.channels))
365+ removeSecurityProxy(job.channels),
366+ build_request=job.build_request)
367 self.assertRequestedBuildsMatch(builds, job, ["mips64el", "riscv64"])
368
369- def test_requestBuilds_unsupported_remote(self):
370+ def test_requestBuildsFromJob_unsupported_remote(self):
371 # If the snap is based on an external Git repository from which we
372 # don't support fetching blobs, requestBuildsFromJob falls back to
373 # requesting builds for all configured architectures.
374@@ -463,9 +498,43 @@
375 with person_logged_in(job.requester):
376 builds = job.snap.requestBuildsFromJob(
377 job.requester, job.archive, job.pocket,
378- removeSecurityProxy(job.channels))
379+ removeSecurityProxy(job.channels),
380+ build_request=job.build_request)
381 self.assertRequestedBuildsMatch(builds, job, ["mips64el", "riscv64"])
382
383+ def test_requestBuildsFromJob_triggers_webhooks(self):
384+ # requestBuildsFromJob triggers webhooks, and the payload includes a
385+ # link to the build request.
386+ self.useFixture(GitHostingFixture(blob=dedent("""\
387+ architectures:
388+ - build-on: avr
389+ - build-on: riscv64
390+ """)))
391+ job = self.makeRequestBuildsJob(["avr", "riscv64", "sparc"])
392+ hook = self.factory.makeWebhook(
393+ target=job.snap, event_types=["snap:build:0.1"])
394+ with person_logged_in(job.requester):
395+ builds = job.snap.requestBuildsFromJob(
396+ job.requester, job.archive, job.pocket,
397+ removeSecurityProxy(job.channels),
398+ build_request=job.build_request)
399+ self.assertEqual(2, len(builds))
400+ self.assertThat(hook.deliveries, MatchesSetwise(*(
401+ MatchesStructure(
402+ event_type=Equals("snap:build:0.1"),
403+ payload=MatchesDict({
404+ "snap_build": Equals(canonical_url(
405+ build, force_local_path=True)),
406+ "action": Equals("created"),
407+ "snap": Equals(canonical_url(
408+ job.snap, force_local_path=True)),
409+ "build_request": Equals(canonical_url(
410+ job.build_request, force_local_path=True)),
411+ "status": Equals("Needs building"),
412+ "store_upload_status": Equals("Unscheduled"),
413+ }))
414+ for build in builds)))
415+
416 def test_requestAutoBuilds(self):
417 # requestAutoBuilds creates new builds for all configured
418 # architectures with appropriate parameters.
419
420=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
421--- lib/lp/snappy/tests/test_snapbuild.py 2018-02-08 12:47:44 +0000
422+++ lib/lp/snappy/tests/test_snapbuild.py 2018-08-03 14:22:02 +0000
423@@ -20,6 +20,7 @@
424 import pytz
425 from testtools.matchers import (
426 Equals,
427+ Is,
428 MatchesDict,
429 MatchesStructure,
430 )
431@@ -241,6 +242,7 @@
432 "action": Equals("status-changed"),
433 "snap": Equals(
434 canonical_url(self.build.snap, force_local_path=True)),
435+ "build_request": Is(None),
436 "status": Equals("Successfully built"),
437 "store_upload_status": Equals("Unscheduled"),
438 }
439@@ -516,6 +518,7 @@
440 "action": Equals("status-changed"),
441 "snap": Equals(
442 canonical_url(self.build.snap, force_local_path=True)),
443+ "build_request": Is(None),
444 "status": Equals("Successfully built"),
445 "store_upload_status": Equals("Pending"),
446 }
447
448=== modified file 'lib/lp/snappy/tests/test_snapbuildjob.py'
449--- lib/lp/snappy/tests/test_snapbuildjob.py 2018-02-19 17:48:22 +0000
450+++ lib/lp/snappy/tests/test_snapbuildjob.py 2018-08-03 14:22:02 +0000
451@@ -1,4 +1,4 @@
452-# Copyright 2016-2017 Canonical Ltd. This software is licensed under the
453+# Copyright 2016-2018 Canonical Ltd. This software is licensed under the
454 # GNU Affero General Public License version 3 (see the file LICENSE).
455
456 """Tests for snap build jobs."""
457@@ -12,6 +12,7 @@
458 from fixtures import FakeLogger
459 from testtools.matchers import (
460 Equals,
461+ Is,
462 MatchesDict,
463 MatchesListwise,
464 MatchesStructure,
465@@ -125,6 +126,7 @@
466 "action": Equals("status-changed"),
467 "snap": Equals(
468 canonical_url(snapbuild.snap, force_local_path=True)),
469+ "build_request": Is(None),
470 "status": Equals("Successfully built"),
471 "store_upload_status": Equals(expected),
472 } for expected in expected_store_upload_statuses]
473
474=== modified file 'lib/lp/snappy/tests/test_snapjob.py'
475--- lib/lp/snappy/tests/test_snapjob.py 2018-06-15 12:54:27 +0000
476+++ lib/lp/snappy/tests/test_snapjob.py 2018-08-03 14:22:02 +0000
477@@ -113,13 +113,14 @@
478 job=MatchesStructure.byEquality(status=JobStatus.COMPLETED),
479 error_message=Is(None),
480 builds=AfterPreprocessing(set, MatchesSetwise(*[
481- MatchesStructure.byEquality(
482- requester=snap.registrant,
483- snap=snap,
484- archive=distroseries.main_archive,
485- distro_arch_series=distroseries[arch],
486- pocket=PackagePublishingPocket.RELEASE,
487- channels={"core": "stable"})
488+ MatchesStructure(
489+ build_request=MatchesStructure.byEquality(id=job.job.id),
490+ requester=Equals(snap.registrant),
491+ snap=Equals(snap),
492+ archive=Equals(distroseries.main_archive),
493+ distro_arch_series=Equals(distroseries[arch]),
494+ pocket=Equals(PackagePublishingPocket.RELEASE),
495+ channels=Equals({"core": "stable"}))
496 for arch in ("avr2001", "x32")]))))
497
498 def test_run_failed(self):