Merge lp:~cjwatson/launchpad/snap-upload-poll-status into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18116
Proposed branch: lp:~cjwatson/launchpad/snap-upload-poll-status
Merge into: lp:launchpad
Diff against target: 797 lines (+448/-10)
11 files modified
lib/lp/snappy/browser/snapbuild.py (+17/-1)
lib/lp/snappy/browser/tests/test_snapbuild.py (+33/-1)
lib/lp/snappy/emailtemplates/snapbuild-scanfailed.txt (+4/-0)
lib/lp/snappy/interfaces/snapbuildjob.py (+4/-0)
lib/lp/snappy/interfaces/snapstoreclient.py (+28/-0)
lib/lp/snappy/mail/snapbuild.py (+20/-0)
lib/lp/snappy/model/snapbuildjob.py (+29/-1)
lib/lp/snappy/model/snapstoreclient.py (+37/-3)
lib/lp/snappy/templates/snapbuild-index.pt (+3/-0)
lib/lp/snappy/tests/test_snapbuildjob.py (+93/-1)
lib/lp/snappy/tests/test_snapstoreclient.py (+180/-3)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-upload-poll-status
Reviewer Review Type Date Requested Status
Celso Providelo (community) Approve
Launchpad code reviewers Pending
Review via email: mp+298030@code.launchpad.net

Commit message

Make SnapStoreUploadJob check the store for status of scanning uploaded packages.

Description of the change

Make SnapStoreUploadJob check the store for status of scanning uploaded packages. Figuring out what happened to store uploads without this is very difficult even for staff, let alone for ordinary users.

The current need to poll is unfortunate, and we don't want to block Launchpad's job runners on the store indefinitely. For the meantime, lacking much in the way of data, I've gone for a gut estimate of trying once a minute for 20 minutes, and we'll see how that goes.

This handles both the current status URL data format and the new one implemented by https://code.launchpad.net/~facundo/software-center-agent/doc-status-url/+merge/296780. We'll need the new format in order to implement automatic releasing to channels, but either is good enough for just finding out whether a package is scanned successfully.

Future work ought to include adding a store upload retry facility to the UI.

To post a comment you must log in.
Revision history for this message
Celso Providelo (cprov) wrote :

Colin,

Thanks for this new feature, it looks very good. It's unfortunate that we ended up having to supporting old and new status response because SCA hasn't settled yet. I am assuming the cleanup (removing the old format) will be quick.

I don't understand the LP jobs mechanics very well, but the code changes seem to reflect the MP description and I am happy to see it in action.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/snappy/browser/snapbuild.py'
2--- lib/lp/snappy/browser/snapbuild.py 2016-05-11 21:40:12 +0000
3+++ lib/lp/snappy/browser/snapbuild.py 2016-06-27 13:20:12 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2015 Canonical Ltd. This software is licensed under the
6+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """SnapBuild views."""
10@@ -16,6 +16,7 @@
11 action,
12 LaunchpadFormView,
13 )
14+from lp.services.job.interfaces.job import JobStatus
15 from lp.services.librarian.browser import (
16 FileNavigationMixin,
17 ProxiedLibraryFileAlias,
18@@ -28,6 +29,7 @@
19 LaunchpadView,
20 Link,
21 Navigation,
22+ structured,
23 )
24 from lp.snappy.interfaces.snapbuild import ISnapBuild
25 from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
26@@ -82,6 +84,20 @@
27 def has_files(self):
28 return bool(self.files)
29
30+ @cachedproperty
31+ def store_upload_status(self):
32+ job = self.context.store_upload_jobs.first()
33+ if job is None:
34+ return None
35+ elif job.job.status in (JobStatus.WAITING, JobStatus.RUNNING):
36+ return "Store upload in progress"
37+ elif job.job.status == JobStatus.COMPLETED:
38+ return structured(
39+ '<a href="%s">Manage this package in the store</a>',
40+ job.store_url)
41+ else:
42+ return structured("Store upload failed: %s", job.error_message)
43+
44
45 class SnapBuildCancelView(LaunchpadFormView):
46 """View for cancelling a snap package build."""
47
48=== modified file 'lib/lp/snappy/browser/tests/test_snapbuild.py'
49--- lib/lp/snappy/browser/tests/test_snapbuild.py 2016-05-11 21:40:12 +0000
50+++ lib/lp/snappy/browser/tests/test_snapbuild.py 2016-06-27 13:20:12 +0000
51@@ -1,4 +1,4 @@
52-# Copyright 2015 Canonical Ltd. This software is licensed under the
53+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
54 # GNU Affero General Public License version 3 (see the file LICENSE).
55
56 """Test snap package build views."""
57@@ -17,8 +17,10 @@
58 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
59 from lp.buildmaster.enums import BuildStatus
60 from lp.services.features.testing import FeatureFixture
61+from lp.services.job.interfaces.job import JobStatus
62 from lp.services.webapp import canonical_url
63 from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
64+from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
65 from lp.testing import (
66 admin_logged_in,
67 ANONYMOUS,
68@@ -81,6 +83,36 @@
69 build_view = create_initialized_view(build, "+index")
70 self.assertEqual([], build_view.files)
71
72+ def test_store_upload_status_in_progress(self):
73+ build = self.factory.makeSnapBuild(status=BuildStatus.FULLYBUILT)
74+ getUtility(ISnapStoreUploadJobSource).create(build)
75+ build_view = create_initialized_view(build, "+index")
76+ self.assertEqual(
77+ "Store upload in progress", build_view.store_upload_status)
78+
79+ def test_store_upload_status_completed(self):
80+ build = self.factory.makeSnapBuild(status=BuildStatus.FULLYBUILT)
81+ job = getUtility(ISnapStoreUploadJobSource).create(build)
82+ naked_job = removeSecurityProxy(job)
83+ naked_job.job._status = JobStatus.COMPLETED
84+ naked_job.store_url = "http://sca.example/dev/click-apps/1/rev/1/"
85+ build_view = create_initialized_view(build, "+index")
86+ self.assertEqual(
87+ '<a href="%s">Manage this package in the store</a>' % (
88+ job.store_url),
89+ build_view.store_upload_status.escapedtext)
90+
91+ def test_store_upload_status_failed(self):
92+ build = self.factory.makeSnapBuild(status=BuildStatus.FULLYBUILT)
93+ job = getUtility(ISnapStoreUploadJobSource).create(build)
94+ naked_job = removeSecurityProxy(job)
95+ naked_job.job._status = JobStatus.FAILED
96+ naked_job.error_message = "Scan failed."
97+ build_view = create_initialized_view(build, "+index")
98+ self.assertEqual(
99+ "Store upload failed: Scan failed.",
100+ build_view.store_upload_status.escapedtext)
101+
102
103 class TestSnapBuildOperations(BrowserTestCase):
104
105
106=== added file 'lib/lp/snappy/emailtemplates/snapbuild-scanfailed.txt'
107--- lib/lp/snappy/emailtemplates/snapbuild-scanfailed.txt 1970-01-01 00:00:00 +0000
108+++ lib/lp/snappy/emailtemplates/snapbuild-scanfailed.txt 2016-06-27 13:20:12 +0000
109@@ -0,0 +1,4 @@
110+Launchpad uploaded this snap package to the store, but the store failed to
111+scan it:
112+
113+ %(store_error_message)s
114
115=== modified file 'lib/lp/snappy/interfaces/snapbuildjob.py'
116--- lib/lp/snappy/interfaces/snapbuildjob.py 2016-05-06 16:34:21 +0000
117+++ lib/lp/snappy/interfaces/snapbuildjob.py 2016-06-27 13:20:12 +0000
118@@ -48,6 +48,10 @@
119 error_message = TextLine(
120 title=_("Error message"), required=False, readonly=True)
121
122+ store_url = TextLine(
123+ title=_("The URL on the store corresponding to this build"),
124+ required=False, readonly=True)
125+
126
127 class ISnapStoreUploadJobSource(IJobSource):
128
129
130=== modified file 'lib/lp/snappy/interfaces/snapstoreclient.py'
131--- lib/lp/snappy/interfaces/snapstoreclient.py 2016-06-03 14:49:49 +0000
132+++ lib/lp/snappy/interfaces/snapstoreclient.py 2016-06-27 13:20:12 +0000
133@@ -9,10 +9,13 @@
134 __all__ = [
135 'BadRefreshResponse',
136 'BadRequestPackageUploadResponse',
137+ 'BadScanStatusResponse',
138 'BadUploadResponse',
139 'ISnapStoreClient',
140 'NeedsRefreshResponse',
141+ 'ScanFailedResponse',
142 'UnauthorizedUploadResponse',
143+ 'UploadNotScannedYetResponse',
144 ]
145
146 from zope.interface import Interface
147@@ -38,6 +41,18 @@
148 pass
149
150
151+class BadScanStatusResponse(Exception):
152+ pass
153+
154+
155+class UploadNotScannedYetResponse(Exception):
156+ pass
157+
158+
159+class ScanFailedResponse(Exception):
160+ pass
161+
162+
163 class ISnapStoreClient(Interface):
164 """Interface for the API provided by the snap store."""
165
166@@ -59,6 +74,7 @@
167 """Upload a snap build to the store.
168
169 :param snapbuild: The `ISnapBuild` to upload.
170+ :return: A URL to poll for upload processing status.
171 """
172
173 def refreshDischargeMacaroon(snap):
174@@ -66,3 +82,15 @@
175
176 :param snap: An `ISnap` whose discharge macaroon needs to be refreshed.
177 """
178+
179+ def checkStatus(status_url):
180+ """Poll the store once for upload scan status.
181+
182+ :param status_url: A URL as returned by `upload`.
183+ :raises UploadNotScannedYetResponse: if the store has not yet
184+ scanned the upload.
185+ :raises BadScanStatusResponse: if the store failed to scan the
186+ upload.
187+ :return: A URL on the store with further information about this
188+ upload.
189+ """
190
191=== modified file 'lib/lp/snappy/mail/snapbuild.py'
192--- lib/lp/snappy/mail/snapbuild.py 2016-06-03 14:49:49 +0000
193+++ lib/lp/snappy/mail/snapbuild.py 2016-06-27 13:20:12 +0000
194@@ -46,6 +46,20 @@
195 config.canonical.noreply_from_address,
196 "snap-build-upload-unauthorized", build)
197
198+ @classmethod
199+ def forUploadScanFailure(cls, build):
200+ """Create a mailer for notifying about store upload scan failures.
201+
202+ :param build: The relevant build.
203+ """
204+ requester = build.requester
205+ recipients = {requester: RecipientReason.forBuildRequester(requester)}
206+ return cls(
207+ "Store upload scan failed for %(snap_name)s",
208+ "snapbuild-scanfailed.txt", recipients,
209+ config.canonical.noreply_from_address,
210+ "snap-build-upload-scan-failed", build)
211+
212 def __init__(self, subject, template_name, recipients, from_address,
213 notification_type, build):
214 super(SnapBuildMailer, self).__init__(
215@@ -62,6 +76,11 @@
216 def _getTemplateParams(self, email, recipient):
217 """See `BaseMailer`."""
218 build = self.build
219+ upload_job = build.store_upload_jobs.first()
220+ if upload_job is None or upload_job.error_message is None:
221+ error_message = ""
222+ else:
223+ error_message = upload_job.error_message
224 params = super(SnapBuildMailer, self)._getTemplateParams(
225 email, recipient)
226 params.update({
227@@ -80,6 +99,7 @@
228 "build_url": canonical_url(build),
229 "snap_authorize_url": canonical_url(
230 build.snap, view_name="+authorize"),
231+ "store_error_message": error_message,
232 })
233 if build.duration is not None:
234 duration_formatter = DurationFormatterAPI(build.duration)
235
236=== modified file 'lib/lp/snappy/model/snapbuildjob.py'
237--- lib/lp/snappy/model/snapbuildjob.py 2016-06-03 14:49:49 +0000
238+++ lib/lp/snappy/model/snapbuildjob.py 2016-06-27 13:20:12 +0000
239@@ -12,6 +12,8 @@
240 'SnapStoreUploadJob',
241 ]
242
243+from datetime import timedelta
244+
245 from lazr.delegates import delegate_to
246 from lazr.enum import (
247 DBEnumeratedType,
248@@ -48,8 +50,11 @@
249 ISnapStoreUploadJobSource,
250 )
251 from lp.snappy.interfaces.snapstoreclient import (
252+ BadScanStatusResponse,
253 ISnapStoreClient,
254+ ScanFailedResponse,
255 UnauthorizedUploadResponse,
256+ UploadNotScannedYetResponse,
257 )
258 from lp.snappy.mail.snapbuild import SnapBuildMailer
259
260@@ -159,7 +164,12 @@
261
262 class_job_type = SnapBuildJobType.STORE_UPLOAD
263
264+ user_error_types = (UnauthorizedUploadResponse, ScanFailedResponse)
265+
266 # XXX cjwatson 2016-05-04: identify transient upload failures and retry
267+ retry_error_types = (UploadNotScannedYetResponse,)
268+ retry_delay = timedelta(minutes=1)
269+ max_retries = 20
270
271 config = config.ISnapStoreUploadJobSource
272
273@@ -181,11 +191,26 @@
274 """See `ISnapStoreUploadJob`."""
275 self.metadata["error_message"] = message
276
277+ @property
278+ def store_url(self):
279+ """See `ISnapStoreUploadJob`."""
280+ return self.metadata.get("store_url")
281+
282+ @store_url.setter
283+ def store_url(self, url):
284+ """See `ISnapStoreUploadJob`."""
285+ self.metadata["store_url"] = url
286+
287 def run(self):
288 """See `IRunnableJob`."""
289+ client = getUtility(ISnapStoreClient)
290 try:
291- getUtility(ISnapStoreClient).upload(self.snapbuild)
292+ if "status_url" not in self.metadata:
293+ self.metadata["status_url"] = client.upload(self.snapbuild)
294+ self.store_url = client.checkStatus(self.metadata["status_url"])
295 self.error_message = None
296+ except self.retry_error_types:
297+ raise
298 except Exception as e:
299 # Abort work done so far, but make sure that we commit the error
300 # message.
301@@ -194,5 +219,8 @@
302 if isinstance(e, UnauthorizedUploadResponse):
303 mailer = SnapBuildMailer.forUnauthorizedUpload(self.snapbuild)
304 mailer.sendAll()
305+ elif isinstance(e, (BadScanStatusResponse, ScanFailedResponse)):
306+ mailer = SnapBuildMailer.forUploadScanFailure(self.snapbuild)
307+ mailer.sendAll()
308 transaction.commit()
309 raise
310
311=== modified file 'lib/lp/snappy/model/snapstoreclient.py'
312--- lib/lp/snappy/model/snapstoreclient.py 2016-06-06 13:03:31 +0000
313+++ lib/lp/snappy/model/snapstoreclient.py 2016-06-27 13:20:12 +0000
314@@ -25,10 +25,13 @@
315 from lp.snappy.interfaces.snapstoreclient import (
316 BadRefreshResponse,
317 BadRequestPackageUploadResponse,
318+ BadScanStatusResponse,
319 BadUploadResponse,
320 ISnapStoreClient,
321 NeedsRefreshResponse,
322+ ScanFailedResponse,
323 UnauthorizedUploadResponse,
324+ UploadNotScannedYetResponse,
325 )
326
327
328@@ -166,11 +169,16 @@
329 # that's currently difficult in jobs.
330 try:
331 assert snap.store_secrets is not None
332- urlfetch(
333+ response = urlfetch(
334 upload_url, method="POST", json=data,
335 auth=MacaroonAuth(
336 snap.store_secrets["root"],
337 snap.store_secrets["discharge"]))
338+ response_data = response.json()
339+ if "status_details_url" in response_data:
340+ return response_data["status_details_url"]
341+ else:
342+ return response_data["status_url"]
343 except requests.HTTPError as e:
344 if e.response.status_code == 401:
345 if (e.response.headers.get("WWW-Authenticate") ==
346@@ -186,12 +194,12 @@
347 for _, lfa, lfc in snapbuild.getFiles():
348 upload_data = self._uploadFile(lfa, lfc)
349 try:
350- self._uploadApp(snapbuild.snap, upload_data)
351+ return self._uploadApp(snapbuild.snap, upload_data)
352 except NeedsRefreshResponse:
353 # Try to automatically refresh the discharge macaroon and
354 # retry the upload.
355 self.refreshDischargeMacaroon(snapbuild.snap)
356- self._uploadApp(snapbuild.snap, upload_data)
357+ return self._uploadApp(snapbuild.snap, upload_data)
358
359 @classmethod
360 def refreshDischargeMacaroon(cls, snap):
361@@ -211,3 +219,29 @@
362 snap.store_secrets = new_secrets
363 except requests.HTTPError as e:
364 raise BadRefreshResponse(e.args[0])
365+
366+ @classmethod
367+ def checkStatus(cls, status_url):
368+ try:
369+ response = urlfetch(status_url)
370+ response_data = response.json()
371+ if "completed" in response_data:
372+ # Old status format.
373+ if not response_data["completed"]:
374+ raise UploadNotScannedYetResponse()
375+ elif not response_data["application_url"]:
376+ raise ScanFailedResponse(response_data["message"])
377+ else:
378+ return response_data["application_url"]
379+ else:
380+ # New status format.
381+ if not response_data["processed"]:
382+ raise UploadNotScannedYetResponse()
383+ elif "errors" in response_data:
384+ error_message = "\n".join(
385+ error["message"] for error in response_data["errors"])
386+ raise ScanFailedResponse(error_message)
387+ else:
388+ return response_data["url"]
389+ except requests.HTTPError as e:
390+ raise BadScanStatusResponse(e.args[0])
391
392=== modified file 'lib/lp/snappy/templates/snapbuild-index.pt'
393--- lib/lp/snappy/templates/snapbuild-index.pt 2016-05-11 21:40:12 +0000
394+++ lib/lp/snappy/templates/snapbuild-index.pt 2016-06-27 13:20:12 +0000
395@@ -159,6 +159,9 @@
396 tal:attributes="href context/upload_log_url">uploadlog</a>
397 (<span tal:replace="file/content/filesize/fmt:bytes" />)
398 </li>
399+ <li tal:define="store_upload_status view/store_upload_status"
400+ tal:condition="store_upload_status"
401+ tal:content="structure store_upload_status" />
402 </ul>
403
404 <div
405
406=== modified file 'lib/lp/snappy/tests/test_snapbuildjob.py'
407--- lib/lp/snappy/tests/test_snapbuildjob.py 2016-06-03 14:49:49 +0000
408+++ lib/lp/snappy/tests/test_snapbuildjob.py 2016-06-27 13:20:12 +0000
409@@ -7,10 +7,12 @@
410
411 __metaclass__ = type
412
413+from fixtures import FakeLogger
414 from zope.interface import implementer
415
416 from lp.services.config import config
417 from lp.services.features.testing import FeatureFixture
418+from lp.services.job.interfaces.job import JobStatus
419 from lp.services.job.runner import JobRunner
420 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
421 from lp.snappy.interfaces.snapbuildjob import (
422@@ -18,8 +20,10 @@
423 ISnapStoreUploadJob,
424 )
425 from lp.snappy.interfaces.snapstoreclient import (
426+ BadScanStatusResponse,
427 ISnapStoreClient,
428 UnauthorizedUploadResponse,
429+ UploadNotScannedYetResponse,
430 )
431 from lp.snappy.model.snapbuildjob import (
432 SnapBuildJob,
433@@ -42,6 +46,7 @@
434
435 def __init__(self):
436 self.upload = FakeMethod()
437+ self.checkStatus = FakeMethod()
438
439
440 class TestSnapBuildJob(TestCaseWithFactory):
441@@ -67,6 +72,8 @@
442 def setUp(self):
443 super(TestSnapStoreUploadJob, self).setUp()
444 self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
445+ self.status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
446+ self.store_url = "http://sca.example/dev/click-apps/1/rev/1/"
447
448 def test_provides_interface(self):
449 # `SnapStoreUploadJob` objects provide `ISnapStoreUploadJob`.
450@@ -82,16 +89,20 @@
451 "<SnapStoreUploadJob for %s>" % snapbuild.title, repr(job))
452
453 def test_run(self):
454- # The job uploads the build to the store.
455+ # The job uploads the build to the store and records the store URL.
456 snapbuild = self.factory.makeSnapBuild()
457 self.assertContentEqual([], snapbuild.store_upload_jobs)
458 job = SnapStoreUploadJob.create(snapbuild)
459 client = FakeSnapStoreClient()
460+ client.upload.result = self.status_url
461+ client.checkStatus.result = self.store_url
462 self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
463 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
464 JobRunner([job]).runAll()
465 self.assertEqual([((snapbuild,), {})], client.upload.calls)
466+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
467 self.assertContentEqual([job], snapbuild.store_upload_jobs)
468+ self.assertEqual(self.store_url, job.store_url)
469 self.assertIsNone(job.error_message)
470 self.assertEqual([], pop_notifications())
471
472@@ -106,7 +117,9 @@
473 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
474 JobRunner([job]).runAll()
475 self.assertEqual([((snapbuild,), {})], client.upload.calls)
476+ self.assertEqual([], client.checkStatus.calls)
477 self.assertContentEqual([job], snapbuild.store_upload_jobs)
478+ self.assertIsNone(job.store_url)
479 self.assertEqual("An upload failure", job.error_message)
480 self.assertEqual([], pop_notifications())
481
482@@ -124,7 +137,9 @@
483 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
484 JobRunner([job]).runAll()
485 self.assertEqual([((snapbuild,), {})], client.upload.calls)
486+ self.assertEqual([], client.checkStatus.calls)
487 self.assertContentEqual([job], snapbuild.store_upload_jobs)
488+ self.assertIsNone(job.store_url)
489 self.assertEqual("Authorization failed.", job.error_message)
490 [notification] = pop_notifications()
491 self.assertEqual(
492@@ -147,3 +162,80 @@
493 self.assertEqual(
494 "http://launchpad.dev/~requester/+snap/test-snap/+build/%d\n"
495 "You are the requester of the build.\n" % snapbuild.id, footer)
496+
497+ def test_run_scan_pending_retries(self):
498+ # A run that finds that the store has not yet finished scanning the
499+ # package schedules itself to be retried.
500+ self.useFixture(FakeLogger())
501+ snapbuild = self.factory.makeSnapBuild()
502+ self.assertContentEqual([], snapbuild.store_upload_jobs)
503+ job = SnapStoreUploadJob.create(snapbuild)
504+ client = FakeSnapStoreClient()
505+ client.upload.result = self.status_url
506+ client.checkStatus.failure = UploadNotScannedYetResponse()
507+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
508+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
509+ JobRunner([job]).runAll()
510+ self.assertEqual([((snapbuild,), {})], client.upload.calls)
511+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
512+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
513+ self.assertIsNone(job.store_url)
514+ self.assertIsNone(job.error_message)
515+ self.assertEqual([], pop_notifications())
516+ self.assertEqual(JobStatus.WAITING, job.job.status)
517+ # Try again. The upload part of the job is not retried, and this
518+ # time the scan completes.
519+ job.lease_expires = None
520+ job.scheduled_start = None
521+ client.upload.calls = []
522+ client.checkStatus.calls = []
523+ client.checkStatus.failure = None
524+ client.checkStatus.result = self.store_url
525+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
526+ JobRunner([job]).runAll()
527+ self.assertEqual([], client.upload.calls)
528+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
529+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
530+ self.assertEqual(self.store_url, job.store_url)
531+ self.assertIsNone(job.error_message)
532+ self.assertEqual([], pop_notifications())
533+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
534+
535+ def test_run_scan_failure_notifies(self):
536+ # A run that gets a scan failure from the store sends mail.
537+ requester = self.factory.makePerson(name="requester")
538+ snapbuild = self.factory.makeSnapBuild(
539+ requester=requester, name="test-snap", owner=requester)
540+ self.assertContentEqual([], snapbuild.store_upload_jobs)
541+ job = SnapStoreUploadJob.create(snapbuild)
542+ client = FakeSnapStoreClient()
543+ client.upload.result = self.status_url
544+ client.checkStatus.failure = BadScanStatusResponse("Scan failed.")
545+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
546+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
547+ JobRunner([job]).runAll()
548+ self.assertEqual([((snapbuild,), {})], client.upload.calls)
549+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
550+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
551+ self.assertIsNone(job.store_url)
552+ self.assertEqual("Scan failed.", job.error_message)
553+ [notification] = pop_notifications()
554+ self.assertEqual(
555+ config.canonical.noreply_from_address, notification["From"])
556+ self.assertEqual(
557+ "Requester <%s>" % requester.preferredemail.email,
558+ notification["To"])
559+ subject = notification["Subject"].replace("\n ", " ")
560+ self.assertEqual("Store upload scan failed for test-snap", subject)
561+ self.assertEqual(
562+ "Requester", notification["X-Launchpad-Message-Rationale"])
563+ self.assertEqual(
564+ requester.name, notification["X-Launchpad-Message-For"])
565+ self.assertEqual(
566+ "snap-build-upload-scan-failed",
567+ notification["X-Launchpad-Notification-Type"])
568+ body, footer = notification.get_payload(decode=True).split("\n-- \n")
569+ self.assertIn("Scan failed.", body)
570+ self.assertEqual(
571+ "http://launchpad.dev/~requester/+snap/test-snap/+build/%d\n"
572+ "You are the requester of the build.\n" % snapbuild.id, footer)
573
574=== modified file 'lib/lp/snappy/tests/test_snapstoreclient.py'
575--- lib/lp/snappy/tests/test_snapstoreclient.py 2016-06-06 13:03:31 +0000
576+++ lib/lp/snappy/tests/test_snapstoreclient.py 2016-06-27 13:20:12 +0000
577@@ -43,8 +43,11 @@
578 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
579 from lp.snappy.interfaces.snapstoreclient import (
580 BadRequestPackageUploadResponse,
581+ BadScanStatusResponse,
582 ISnapStoreClient,
583+ ScanFailedResponse,
584 UnauthorizedUploadResponse,
585+ UploadNotScannedYetResponse,
586 )
587 from lp.snappy.model.snapstoreclient import (
588 InvalidStoreSecretsError,
589@@ -199,7 +202,16 @@
590 @urlmatch(path=r".*/snap-push/$")
591 def _snap_push_handler(self, url, request):
592 self.snap_push_request = request
593- return {"status_code": 202, "content": {"success": True}}
594+ return {
595+ "status_code": 202,
596+ "content": {
597+ "success": True,
598+ "status_url": (
599+ "http://sca.example/dev/api/"
600+ "click-scan-complete/updown/1/"),
601+ "status_details_url": (
602+ "http://sca.example/dev/api/snaps/1/builds/1/status"),
603+ }}
604
605 @urlmatch(path=r".*/api/v2/tokens/refresh$")
606 def _macaroon_refresh_handler(self, url, request):
607@@ -273,7 +285,9 @@
608 self.factory.makeSnapFile(snapbuild=snapbuild, libraryfile=lfa)
609 transaction.commit()
610 with HTTMock(self._unscanned_upload_handler, self._snap_push_handler):
611- self.client.upload(snapbuild)
612+ self.assertEqual(
613+ "http://sca.example/dev/api/snaps/1/builds/1/status",
614+ self.client.upload(snapbuild))
615 self.assertThat(self.unscanned_upload_request, RequestMatches(
616 url=Equals("http://updown.example/unscanned-upload/"),
617 method=Equals("POST"),
618@@ -340,11 +354,38 @@
619 transaction.commit()
620 with HTTMock(self._unscanned_upload_handler, snap_push_handler,
621 self._macaroon_refresh_handler):
622- self.client.upload(snapbuild)
623+ self.assertEqual(
624+ "http://sca.example/dev/api/snaps/1/builds/1/status",
625+ self.client.upload(snapbuild))
626 self.assertEqual(2, snap_push_handler.call_count)
627 self.assertNotEqual(
628 store_secrets["discharge"], snap.store_secrets["discharge"])
629
630+ def test_upload_old_status_url(self):
631+ @urlmatch(path=r".*/snap-push/$")
632+ def snap_push_handler(url, request):
633+ return {
634+ "status_code": 202,
635+ "content": {
636+ "success": True,
637+ "status_url": (
638+ "http://sca.example/dev/api/"
639+ "click-scan-complete/updown/1/"),
640+ }}
641+
642+ snap = self.factory.makeSnap(
643+ store_upload=True,
644+ store_series=self.factory.makeSnappySeries(name="rolling"),
645+ store_name="test-snap", store_secrets=self._make_store_secrets())
646+ snapbuild = self.factory.makeSnapBuild(snap=snap)
647+ lfa = self.factory.makeLibraryFileAlias(content="dummy snap content")
648+ self.factory.makeSnapFile(snapbuild=snapbuild, libraryfile=lfa)
649+ transaction.commit()
650+ with HTTMock(self._unscanned_upload_handler, snap_push_handler):
651+ self.assertEqual(
652+ "http://sca.example/dev/api/click-scan-complete/updown/1/",
653+ self.client.upload(snapbuild))
654+
655 def test_refresh_discharge_macaroon(self):
656 store_secrets = self._make_store_secrets()
657 snap = self.factory.makeSnap(
658@@ -361,3 +402,139 @@
659 json_data={"discharge_macaroon": store_secrets["discharge"]}))
660 self.assertNotEqual(
661 store_secrets["discharge"], snap.store_secrets["discharge"])
662+
663+ def test_checkStatus_old_pending(self):
664+ @all_requests
665+ def handler(url, request):
666+ return {
667+ "status_code": 200,
668+ "content": {
669+ "completed": False, "application_url": "",
670+ "revision": None,
671+ "message": "Task 1 is waiting for execution.",
672+ "package_name": None,
673+ }}
674+
675+ status_url = "http://sca.example/dev/api/click-scan-complete/updown/1/"
676+ with HTTMock(handler):
677+ self.assertRaises(
678+ UploadNotScannedYetResponse, self.client.checkStatus,
679+ status_url)
680+
681+ def test_checkStatus_old_error(self):
682+ @all_requests
683+ def handler(url, request):
684+ return {
685+ "status_code": 200,
686+ "content": {
687+ "completed": True, "application_url": "", "revision": None,
688+ "message": "You cannot use that reserved namespace.",
689+ "package_name": None,
690+ }}
691+
692+ status_url = "http://sca.example/dev/api/click-scan-complete/updown/1/"
693+ with HTTMock(handler):
694+ self.assertRaisesWithContent(
695+ ScanFailedResponse, b"You cannot use that reserved namespace.",
696+ self.client.checkStatus, status_url)
697+
698+ def test_checkStatus_old_success(self):
699+ @all_requests
700+ def handler(url, request):
701+ return {
702+ "status_code": 200,
703+ "content": {
704+ "completed": True,
705+ "application_url": "http://sca.example/dev/click-apps/1/",
706+ "revision": 1, "message": "", "package_name": "test",
707+ }}
708+
709+ status_url = "http://sca.example/dev/api/click-scan-complete/updown/1/"
710+ with HTTMock(handler):
711+ self.assertEqual(
712+ "http://sca.example/dev/click-apps/1/",
713+ self.client.checkStatus(status_url))
714+
715+ def test_checkStatus_new_pending(self):
716+ @all_requests
717+ def handler(url, request):
718+ return {
719+ "status_code": 200,
720+ "content": {
721+ "code": "being_processed", "processed": False,
722+ "can_release": False,
723+ }}
724+
725+ status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
726+ with HTTMock(handler):
727+ self.assertRaises(
728+ UploadNotScannedYetResponse, self.client.checkStatus,
729+ status_url)
730+
731+ def test_checkStatus_new_error(self):
732+ @all_requests
733+ def handler(url, request):
734+ return {
735+ "status_code": 200,
736+ "content": {
737+ "code": "processing_error", "processed": True,
738+ "can_release": False,
739+ "errors": [
740+ {"code": None,
741+ "message": "You cannot use that reserved namespace.",
742+ }],
743+ }}
744+
745+ status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
746+ with HTTMock(handler):
747+ self.assertRaisesWithContent(
748+ ScanFailedResponse,
749+ b"You cannot use that reserved namespace.",
750+ self.client.checkStatus, status_url)
751+
752+ def test_checkStatus_new_review_error(self):
753+ @all_requests
754+ def handler(url, request):
755+ return {
756+ "status_code": 200,
757+ "content": {
758+ "code": "processing_error", "processed": True,
759+ "can_release": False,
760+ "errors": [{"code": None, "message": "Review failed."}],
761+ "url": "http://sca.example/dev/click-apps/1/rev/1/",
762+ }}
763+
764+ status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
765+ with HTTMock(handler):
766+ self.assertRaisesWithContent(
767+ ScanFailedResponse, b"Review failed.",
768+ self.client.checkStatus, status_url)
769+
770+ def test_checkStatus_new_complete(self):
771+ @all_requests
772+ def handler(url, request):
773+ return {
774+ "status_code": 200,
775+ "content": {
776+ "code": "ready_to_release", "processed": True,
777+ "can_release": True,
778+ "url": "http://sca.example/dev/click-apps/1/rev/1/",
779+ "revision": 1,
780+ }}
781+
782+ status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
783+ with HTTMock(handler):
784+ self.assertEqual(
785+ "http://sca.example/dev/click-apps/1/rev/1/",
786+ self.client.checkStatus(status_url))
787+
788+ def test_checkStatus_404(self):
789+ @all_requests
790+ def handler(url, request):
791+ return {"status_code": 404, "reason": b"Not found"}
792+
793+ status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
794+ with HTTMock(handler):
795+ self.assertRaisesWithContent(
796+ BadScanStatusResponse, b"404 Client Error: Not found",
797+ self.client.checkStatus, status_url)