Merge lp:~cjwatson/launchpad/snap-webhook-store-status into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18335
Proposed branch: lp:~cjwatson/launchpad/snap-webhook-store-status
Merge into: lp:launchpad
Diff against target: 493 lines (+173/-28)
7 files modified
database/schema/security.cfg (+4/-2)
lib/lp/snappy/configure.zcml (+5/-1)
lib/lp/snappy/interfaces/snapbuildjob.py (+7/-1)
lib/lp/snappy/model/snapbuildjob.py (+40/-1)
lib/lp/snappy/subscribers/snapbuild.py (+13/-4)
lib/lp/snappy/tests/test_snapbuild.py (+32/-0)
lib/lp/snappy/tests/test_snapbuildjob.py (+72/-19)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-webhook-store-status
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+320295@code.launchpad.net

Commit message

Extend snap webhooks to include store_upload_status.

Description of the change

This is needed for build.snapcraft.io, so that it can increment metrics when snaps are released to edge; but in general it seems reasonable to include store upload status as well as build status in the webhook payload and trigger deliveries when it changes.

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 'database/schema/security.cfg'
2--- database/schema/security.cfg 2016-11-23 03:42:51 +0000
3+++ database/schema/security.cfg 2017-03-20 00:43:16 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2017 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8 #
9 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
10@@ -2565,7 +2565,7 @@
11 public.distroarchseries = SELECT
12 public.distroseries = SELECT
13 public.emailaddress = SELECT
14-public.job = SELECT, UPDATE
15+public.job = SELECT, INSERT, UPDATE
16 public.libraryfilealias = SELECT
17 public.libraryfilecontent = SELECT
18 public.person = SELECT
19@@ -2576,3 +2576,5 @@
20 public.snapfile = SELECT
21 public.snappyseries = SELECT
22 public.teammembership = SELECT
23+public.webhook = SELECT
24+public.webhookjob = SELECT, INSERT
25
26=== modified file 'lib/lp/snappy/configure.zcml'
27--- lib/lp/snappy/configure.zcml 2016-06-20 21:17:58 +0000
28+++ lib/lp/snappy/configure.zcml 2017-03-20 00:43:16 +0000
29@@ -1,4 +1,4 @@
30-<!-- Copyright 2015-2016 Canonical Ltd. This software is licensed under the
31+<!-- Copyright 2015-2017 Canonical Ltd. This software is licensed under the
32 GNU Affero General Public License version 3 (see the file LICENSE).
33 -->
34
35@@ -139,6 +139,10 @@
36 <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapBuildJob" />
37 <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapStoreUploadJob" />
38 </class>
39+ <subscriber
40+ for="lp.snappy.interfaces.snapbuild.ISnapBuild
41+ lp.snappy.interfaces.snapbuildjob.ISnapBuildStoreUploadStatusChangedEvent"
42+ handler="lp.snappy.subscribers.snapbuild.snap_build_store_upload_status_changed" />
43
44 <webservice:register module="lp.snappy.interfaces.webservice" />
45
46
47=== modified file 'lib/lp/snappy/interfaces/snapbuildjob.py'
48--- lib/lp/snappy/interfaces/snapbuildjob.py 2016-06-21 14:51:06 +0000
49+++ lib/lp/snappy/interfaces/snapbuildjob.py 2017-03-20 00:43:16 +0000
50@@ -1,4 +1,4 @@
51-# Copyright 2016 Canonical Ltd. This software is licensed under the
52+# Copyright 2016-2017 Canonical Ltd. This software is licensed under the
53 # GNU Affero General Public License version 3 (see the file LICENSE).
54
55 """Snap build job interfaces."""
56@@ -8,11 +8,13 @@
57 __metaclass__ = type
58 __all__ = [
59 'ISnapBuildJob',
60+ 'ISnapBuildStoreUploadStatusChangedEvent',
61 'ISnapStoreUploadJob',
62 'ISnapStoreUploadJobSource',
63 ]
64
65 from lazr.restful.fields import Reference
66+from zope.component.interfaces import IObjectEvent
67 from zope.interface import (
68 Attribute,
69 Interface,
70@@ -42,6 +44,10 @@
71 metadata = Attribute(_("A dict of data about the job."))
72
73
74+class ISnapBuildStoreUploadStatusChangedEvent(IObjectEvent):
75+ """The store upload status of a snap package build changed."""
76+
77+
78 class ISnapStoreUploadJob(IRunnableJob):
79 """A Job that uploads a snap build to the store."""
80
81
82=== modified file 'lib/lp/snappy/model/snapbuildjob.py'
83--- lib/lp/snappy/model/snapbuildjob.py 2016-10-16 22:04:39 +0000
84+++ lib/lp/snappy/model/snapbuildjob.py 2017-03-20 00:43:16 +0000
85@@ -1,4 +1,4 @@
86-# Copyright 2016 Canonical Ltd. This software is licensed under the
87+# Copyright 2016-2017 Canonical Ltd. This software is licensed under the
88 # GNU Affero General Public License version 3 (see the file LICENSE).
89
90 """Snap build jobs."""
91@@ -9,6 +9,7 @@
92 __all__ = [
93 'SnapBuildJob',
94 'SnapBuildJobType',
95+ 'SnapBuildStoreUploadStatusChangedEvent',
96 'SnapStoreUploadJob',
97 ]
98
99@@ -26,6 +27,8 @@
100 )
101 import transaction
102 from zope.component import getUtility
103+from zope.component.interfaces import ObjectEvent
104+from zope.event import notify
105 from zope.interface import (
106 implementer,
107 provider,
108@@ -44,8 +47,10 @@
109 Job,
110 )
111 from lp.services.job.runner import BaseRunnableJob
112+from lp.services.propertycache import get_property_cache
113 from lp.snappy.interfaces.snapbuildjob import (
114 ISnapBuildJob,
115+ ISnapBuildStoreUploadStatusChangedEvent,
116 ISnapStoreUploadJob,
117 ISnapStoreUploadJobSource,
118 )
119@@ -164,6 +169,11 @@
120 pass
121
122
123+@implementer(ISnapBuildStoreUploadStatusChangedEvent)
124+class SnapBuildStoreUploadStatusChangedEvent(ObjectEvent):
125+ """See `ISnapBuildStoreUploadStatusChangedEvent`."""
126+
127+
128 @implementer(ISnapStoreUploadJob)
129 @provider(ISnapStoreUploadJobSource)
130 class SnapStoreUploadJob(SnapBuildJobDerived):
131@@ -191,6 +201,8 @@
132 snap_build_job = SnapBuildJob(snapbuild, cls.class_job_type, {})
133 job = cls(snap_build_job)
134 job.celeryRunOnCommit()
135+ del get_property_cache(snapbuild).last_store_upload_job
136+ notify(SnapBuildStoreUploadStatusChangedEvent(snapbuild))
137 return job
138
139 @property
140@@ -213,6 +225,33 @@
141 """See `ISnapStoreUploadJob`."""
142 self.metadata["store_url"] = url
143
144+ # Ideally we'd just override Job._set_status or similar, but
145+ # lazr.delegates makes that difficult, so we use this to override all
146+ # the individual Job lifecycle methods instead.
147+ def _do_lifecycle(self, method, *args, **kwargs):
148+ old_store_upload_status = self.snapbuild.store_upload_status
149+ method(*args, **kwargs)
150+ if self.snapbuild.store_upload_status != old_store_upload_status:
151+ notify(SnapBuildStoreUploadStatusChangedEvent(self.snapbuild))
152+
153+ def start(self, *args, **kwargs):
154+ self._do_lifecycle(self.job.start, *args, **kwargs)
155+
156+ def complete(self, *args, **kwargs):
157+ self._do_lifecycle(self.job.complete, *args, **kwargs)
158+
159+ def fail(self, *args, **kwargs):
160+ self._do_lifecycle(self.job.fail, *args, **kwargs)
161+
162+ def queue(self, *args, **kwargs):
163+ self._do_lifecycle(self.job.queue, *args, **kwargs)
164+
165+ def suspend(self, *args, **kwargs):
166+ self._do_lifecycle(self.job.suspend, *args, **kwargs)
167+
168+ def resume(self, *args, **kwargs):
169+ self._do_lifecycle(self.job.resume, *args, **kwargs)
170+
171 def run(self):
172 """See `IRunnableJob`."""
173 client = getUtility(ISnapStoreClient)
174
175=== modified file 'lib/lp/snappy/subscribers/snapbuild.py'
176--- lib/lp/snappy/subscribers/snapbuild.py 2016-07-19 16:32:46 +0000
177+++ lib/lp/snappy/subscribers/snapbuild.py 2017-03-20 00:43:16 +0000
178@@ -1,4 +1,4 @@
179-# Copyright 2016 Canonical Ltd. This software is licensed under the
180+# Copyright 2016-2017 Canonical Ltd. This software is licensed under the
181 # GNU Affero General Public License version 3 (see the file LICENSE).
182
183 """Event subscribers for snap builds."""
184@@ -19,18 +19,27 @@
185 from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
186
187
188-def snap_build_status_changed(snapbuild, event):
189- """Trigger events when snap package build statuses change."""
190+def _trigger_snap_build_webhook(snapbuild):
191 if getFeatureFlag(SNAP_WEBHOOKS_FEATURE_FLAG):
192 payload = {
193 "snap_build": canonical_url(snapbuild, force_local_path=True),
194 "action": "status-changed",
195 }
196 payload.update(compose_webhook_payload(
197- ISnapBuild, snapbuild, ["snap", "status"]))
198+ ISnapBuild, snapbuild, ["snap", "status", "store_upload_status"]))
199 getUtility(IWebhookSet).trigger(
200 snapbuild.snap, "snap:build:0.1", payload)
201
202+
203+def snap_build_status_changed(snapbuild, event):
204+ """Trigger events when snap package build statuses change."""
205+ _trigger_snap_build_webhook(snapbuild)
206+
207 if (snapbuild.snap.can_upload_to_store and snapbuild.snap.store_upload and
208 snapbuild.status == BuildStatus.FULLYBUILT):
209 getUtility(ISnapStoreUploadJobSource).create(snapbuild)
210+
211+
212+def snap_build_store_upload_status_changed(snapbuild, event):
213+ """Trigger events when snap package build store upload statuses change."""
214+ _trigger_snap_build_webhook(snapbuild)
215
216=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
217--- lib/lp/snappy/tests/test_snapbuild.py 2017-02-27 18:46:38 +0000
218+++ lib/lp/snappy/tests/test_snapbuild.py 2017-03-20 00:43:16 +0000
219@@ -231,6 +231,7 @@
220 "snap": Equals(
221 canonical_url(self.build.snap, force_local_path=True)),
222 "status": Equals("Successfully built"),
223+ "store_upload_status": Equals("Unscheduled"),
224 }
225 delivery = hook.deliveries.one()
226 self.assertThat(
227@@ -471,6 +472,37 @@
228 self.assertEqual(
229 [], list(getUtility(ISnapStoreUploadJobSource).iterReady()))
230
231+ def test_scheduleStoreUpload_triggers_webhooks(self):
232+ # Scheduling a store upload triggers webhooks on the corresponding
233+ # snap.
234+ self.setUpStoreUpload()
235+ self.build.updateStatus(BuildStatus.FULLYBUILT)
236+ self.factory.makeSnapFile(
237+ snapbuild=self.build,
238+ libraryfile=self.factory.makeLibraryFileAlias(db_only=True))
239+ hook = self.factory.makeWebhook(
240+ target=self.build.snap, event_types=["snap:build:0.1"])
241+ self.build.scheduleStoreUpload()
242+ expected_payload = {
243+ "snap_build": Equals(
244+ canonical_url(self.build, force_local_path=True)),
245+ "action": Equals("status-changed"),
246+ "snap": Equals(
247+ canonical_url(self.build.snap, force_local_path=True)),
248+ "status": Equals("Successfully built"),
249+ "store_upload_status": Equals("Pending"),
250+ }
251+ delivery = hook.deliveries.one()
252+ self.assertThat(
253+ delivery, MatchesStructure(
254+ event_type=Equals("snap:build:0.1"),
255+ payload=MatchesDict(expected_payload)))
256+ with dbuser(config.IWebhookDeliveryJobSource.dbuser):
257+ self.assertEqual(
258+ "<WebhookDeliveryJob for webhook %d on %r>" % (
259+ hook.id, hook.target),
260+ repr(delivery))
261+
262
263 class TestSnapBuildSet(TestCaseWithFactory):
264
265
266=== modified file 'lib/lp/snappy/tests/test_snapbuildjob.py'
267--- lib/lp/snappy/tests/test_snapbuildjob.py 2017-01-04 20:52:12 +0000
268+++ lib/lp/snappy/tests/test_snapbuildjob.py 2017-03-20 00:43:16 +0000
269@@ -8,12 +8,20 @@
270 __metaclass__ = type
271
272 from fixtures import FakeLogger
273+from testtools.matchers import (
274+ Equals,
275+ MatchesDict,
276+ MatchesListwise,
277+ MatchesStructure,
278+ )
279 from zope.interface import implementer
280
281+from lp.buildmaster.enums import BuildStatus
282 from lp.services.config import config
283 from lp.services.features.testing import FeatureFixture
284 from lp.services.job.interfaces.job import JobStatus
285 from lp.services.job.runner import JobRunner
286+from lp.services.webapp.publisher import canonical_url
287 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
288 from lp.snappy.interfaces.snapbuildjob import (
289 ISnapBuildJob,
290@@ -92,10 +100,45 @@
291 self.assertEqual(
292 "<SnapStoreUploadJob for %s>" % snapbuild.title, repr(job))
293
294+ def makeSnapBuild(self, **kwargs):
295+ # Make a build with a builder and a webhook.
296+ snapbuild = self.factory.makeSnapBuild(
297+ builder=self.factory.makeBuilder(), **kwargs)
298+ snapbuild.updateStatus(BuildStatus.FULLYBUILT)
299+ self.factory.makeWebhook(
300+ target=snapbuild.snap, event_types=["snap:build:0.1"])
301+ return snapbuild
302+
303+ def assertWebhookDeliveries(self, snapbuild,
304+ expected_store_upload_statuses):
305+ hook = snapbuild.snap.webhooks.one()
306+ deliveries = list(hook.deliveries)
307+ deliveries.reverse()
308+ expected_payloads = [{
309+ "snap_build": Equals(
310+ canonical_url(snapbuild, force_local_path=True)),
311+ "action": Equals("status-changed"),
312+ "snap": Equals(
313+ canonical_url(snapbuild.snap, force_local_path=True)),
314+ "status": Equals("Successfully built"),
315+ "store_upload_status": Equals(expected),
316+ } for expected in expected_store_upload_statuses]
317+ matchers = [
318+ MatchesStructure(
319+ event_type=Equals("snap:build:0.1"),
320+ payload=MatchesDict(expected_payload))
321+ for expected_payload in expected_payloads]
322+ self.assertThat(deliveries, MatchesListwise(matchers))
323+ with dbuser(config.IWebhookDeliveryJobSource.dbuser):
324+ for delivery in deliveries:
325+ self.assertEqual(
326+ "<WebhookDeliveryJob for webhook %d on %r>" % (
327+ hook.id, hook.target),
328+ repr(delivery))
329+
330 def test_run(self):
331 # The job uploads the build to the store and records the store URL.
332- snapbuild = self.factory.makeSnapBuild(
333- builder=self.factory.makeBuilder())
334+ snapbuild = self.makeSnapBuild()
335 self.assertContentEqual([], snapbuild.store_upload_jobs)
336 job = SnapStoreUploadJob.create(snapbuild)
337 client = FakeSnapStoreClient()
338@@ -111,11 +154,11 @@
339 self.assertEqual(self.store_url, job.store_url)
340 self.assertIsNone(job.error_message)
341 self.assertEqual([], pop_notifications())
342+ self.assertWebhookDeliveries(snapbuild, ["Pending", "Uploaded"])
343
344 def test_run_failed(self):
345 # A failed run sets the store upload status to FAILED.
346- snapbuild = self.factory.makeSnapBuild(
347- builder=self.factory.makeBuilder())
348+ snapbuild = self.makeSnapBuild()
349 self.assertContentEqual([], snapbuild.store_upload_jobs)
350 job = SnapStoreUploadJob.create(snapbuild)
351 client = FakeSnapStoreClient()
352@@ -130,15 +173,16 @@
353 self.assertIsNone(job.store_url)
354 self.assertEqual("An upload failure", job.error_message)
355 self.assertEqual([], pop_notifications())
356+ self.assertWebhookDeliveries(
357+ snapbuild, ["Pending", "Failed to upload"])
358
359 def test_run_unauthorized_notifies(self):
360 # A run that gets 401 from the store sends mail.
361 requester = self.factory.makePerson(name="requester")
362 requester_team = self.factory.makeTeam(
363 owner=requester, name="requester-team", members=[requester])
364- snapbuild = self.factory.makeSnapBuild(
365- requester=requester_team, name="test-snap", owner=requester_team,
366- builder=self.factory.makeBuilder())
367+ snapbuild = self.makeSnapBuild(
368+ requester=requester_team, name="test-snap", owner=requester_team)
369 self.assertContentEqual([], snapbuild.store_upload_jobs)
370 job = SnapStoreUploadJob.create(snapbuild)
371 client = FakeSnapStoreClient()
372@@ -177,6 +221,8 @@
373 "http://launchpad.dev/~requester-team/+snap/test-snap/+build/%d\n"
374 "Your team Requester Team is the requester of the build.\n" %
375 snapbuild.id, footer)
376+ self.assertWebhookDeliveries(
377+ snapbuild, ["Pending", "Failed to upload"])
378
379 def test_run_upload_failure_notifies(self):
380 # A run that gets some other upload failure from the store sends
381@@ -184,9 +230,8 @@
382 requester = self.factory.makePerson(name="requester")
383 requester_team = self.factory.makeTeam(
384 owner=requester, name="requester-team", members=[requester])
385- snapbuild = self.factory.makeSnapBuild(
386- requester=requester_team, name="test-snap", owner=requester_team,
387- builder=self.factory.makeBuilder())
388+ snapbuild = self.makeSnapBuild(
389+ requester=requester_team, name="test-snap", owner=requester_team)
390 self.assertContentEqual([], snapbuild.store_upload_jobs)
391 job = SnapStoreUploadJob.create(snapbuild)
392 client = FakeSnapStoreClient()
393@@ -225,13 +270,14 @@
394 self.assertEqual(
395 "%s\nYour team Requester Team is the requester of the build.\n" %
396 build_url, footer)
397+ self.assertWebhookDeliveries(
398+ snapbuild, ["Pending", "Failed to upload"])
399
400 def test_run_scan_pending_retries(self):
401 # A run that finds that the store has not yet finished scanning the
402 # package schedules itself to be retried.
403 self.useFixture(FakeLogger())
404- snapbuild = self.factory.makeSnapBuild(
405- builder=self.factory.makeBuilder())
406+ snapbuild = self.makeSnapBuild()
407 self.assertContentEqual([], snapbuild.store_upload_jobs)
408 job = SnapStoreUploadJob.create(snapbuild)
409 client = FakeSnapStoreClient()
410@@ -248,6 +294,7 @@
411 self.assertIsNone(job.error_message)
412 self.assertEqual([], pop_notifications())
413 self.assertEqual(JobStatus.WAITING, job.job.status)
414+ self.assertWebhookDeliveries(snapbuild, ["Pending"])
415 # Try again. The upload part of the job is not retried, and this
416 # time the scan completes.
417 job.lease_expires = None
418@@ -266,15 +313,15 @@
419 self.assertIsNone(job.error_message)
420 self.assertEqual([], pop_notifications())
421 self.assertEqual(JobStatus.COMPLETED, job.job.status)
422+ self.assertWebhookDeliveries(snapbuild, ["Pending", "Uploaded"])
423
424 def test_run_scan_failure_notifies(self):
425 # A run that gets a scan failure from the store sends mail.
426 requester = self.factory.makePerson(name="requester")
427 requester_team = self.factory.makeTeam(
428 owner=requester, name="requester-team", members=[requester])
429- snapbuild = self.factory.makeSnapBuild(
430- requester=requester_team, name="test-snap", owner=requester_team,
431- builder=self.factory.makeBuilder())
432+ snapbuild = self.makeSnapBuild(
433+ requester=requester_team, name="test-snap", owner=requester_team)
434 self.assertContentEqual([], snapbuild.store_upload_jobs)
435 job = SnapStoreUploadJob.create(snapbuild)
436 client = FakeSnapStoreClient()
437@@ -311,12 +358,13 @@
438 "http://launchpad.dev/~requester-team/+snap/test-snap/+build/%d\n"
439 "Your team Requester Team is the requester of the build.\n" %
440 snapbuild.id, footer)
441+ self.assertWebhookDeliveries(
442+ snapbuild, ["Pending", "Failed to upload"])
443
444 def test_run_release(self):
445 # A run configured to automatically release the package to certain
446 # channels does so.
447- snapbuild = self.factory.makeSnapBuild(
448- store_channels=["stable", "edge"])
449+ snapbuild = self.makeSnapBuild(store_channels=["stable", "edge"])
450 self.assertContentEqual([], snapbuild.store_upload_jobs)
451 job = SnapStoreUploadJob.create(snapbuild)
452 client = FakeSnapStoreClient()
453@@ -332,6 +380,7 @@
454 self.assertEqual(self.store_url, job.store_url)
455 self.assertIsNone(job.error_message)
456 self.assertEqual([], pop_notifications())
457+ self.assertWebhookDeliveries(snapbuild, ["Pending", "Uploaded"])
458
459 def test_run_release_manual_review_notifies(self):
460 # A run configured to automatically release the package to certain
461@@ -340,7 +389,7 @@
462 requester = self.factory.makePerson(name="requester")
463 requester_team = self.factory.makeTeam(
464 owner=requester, name="requester-team", members=[requester])
465- snapbuild = self.factory.makeSnapBuild(
466+ snapbuild = self.makeSnapBuild(
467 requester=requester_team, name="test-snap", owner=requester_team,
468 store_channels=["stable", "edge"])
469 self.assertContentEqual([], snapbuild.store_upload_jobs)
470@@ -382,6 +431,8 @@
471 "http://launchpad.dev/~requester-team/+snap/test-snap/+build/%d\n"
472 "Your team Requester Team is the requester of the build.\n" %
473 snapbuild.id, footer)
474+ self.assertWebhookDeliveries(
475+ snapbuild, ["Pending", "Failed to release to channels"])
476
477 def test_run_release_failure_notifies(self):
478 # A run configured to automatically release the package to certain
479@@ -389,7 +440,7 @@
480 requester = self.factory.makePerson(name="requester")
481 requester_team = self.factory.makeTeam(
482 owner=requester, name="requester-team", members=[requester])
483- snapbuild = self.factory.makeSnapBuild(
484+ snapbuild = self.makeSnapBuild(
485 requester=requester_team, name="test-snap", owner=requester_team,
486 store_channels=["stable", "edge"])
487 self.assertContentEqual([], snapbuild.store_upload_jobs)
488@@ -430,3 +481,5 @@
489 "http://launchpad.dev/~requester-team/+snap/test-snap/+build/%d\n"
490 "Your team Requester Team is the requester of the build.\n" %
491 snapbuild.id, footer)
492+ self.assertWebhookDeliveries(
493+ snapbuild, ["Pending", "Failed to release to channels"])