Merge lp:~cjwatson/launchpad/snap-channels-job into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18143
Proposed branch: lp:~cjwatson/launchpad/snap-channels-job
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-channels-store-client
Diff against target: 358 lines (+196/-9)
5 files modified
lib/lp/snappy/emailtemplates/snapbuild-manualreview.txt (+5/-0)
lib/lp/snappy/emailtemplates/snapbuild-releasefailed.txt (+7/-0)
lib/lp/snappy/mail/snapbuild.py (+33/-2)
lib/lp/snappy/model/snapbuildjob.py (+30/-5)
lib/lp/snappy/tests/test_snapbuildjob.py (+121/-2)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-channels-job
Reviewer Review Type Date Requested Status
Thomi Richards (community) Approve
Launchpad code reviewers Pending
Review via email: mp+298810@code.launchpad.net

Commit message

Automatically release snap packages that have store_channels set.

Description of the change

Automatically release snap packages that have store_channels set.

To post a comment you must log in.
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

+1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'lib/lp/snappy/emailtemplates/snapbuild-manualreview.txt'
--- lib/lp/snappy/emailtemplates/snapbuild-manualreview.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/emailtemplates/snapbuild-manualreview.txt 2016-06-30 17:00:36 +0000
@@ -0,0 +1,5 @@
1This snap package could not be released automatically because it was held
2for manual review. Once it has been approved, you will need to release it
3manually from here:
4
5 %(store_url)s
06
=== added file 'lib/lp/snappy/emailtemplates/snapbuild-releasefailed.txt'
--- lib/lp/snappy/emailtemplates/snapbuild-releasefailed.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/emailtemplates/snapbuild-releasefailed.txt 2016-06-30 17:00:36 +0000
@@ -0,0 +1,7 @@
1Launchpad asked the store to release this snap package, but it failed:
2
3 %(store_error_message)s
4
5You can try to release it manually here:
6
7 %(store_url)s
08
=== modified file 'lib/lp/snappy/mail/snapbuild.py'
--- lib/lp/snappy/mail/snapbuild.py 2016-06-21 14:51:06 +0000
+++ lib/lp/snappy/mail/snapbuild.py 2016-06-30 17:00:36 +0000
@@ -60,6 +60,34 @@
60 config.canonical.noreply_from_address,60 config.canonical.noreply_from_address,
61 "snap-build-upload-scan-failed", build)61 "snap-build-upload-scan-failed", build)
6262
63 @classmethod
64 def forManualReview(cls, build):
65 """Create a mailer for notifying about manual review.
66
67 :param build: The relevant build.
68 """
69 requester = build.requester
70 recipients = {requester: RecipientReason.forBuildRequester(requester)}
71 return cls(
72 "%(snap_name)s held for manual review",
73 "snapbuild-manualreview.txt", recipients,
74 config.canonical.noreply_from_address,
75 "snap-build-release-manual-review", build)
76
77 @classmethod
78 def forReleaseFailure(cls, build):
79 """Create a mailer for notifying about store release failures.
80
81 :param build: The relevant build.
82 """
83 requester = build.requester
84 recipients = {requester: RecipientReason.forBuildRequester(requester)}
85 return cls(
86 "Store release failed for %(snap_name)s",
87 "snapbuild-releasefailed.txt", recipients,
88 config.canonical.noreply_from_address,
89 "snap-build-release-failed", build)
90
63 def __init__(self, subject, template_name, recipients, from_address,91 def __init__(self, subject, template_name, recipients, from_address,
64 notification_type, build):92 notification_type, build):
65 super(SnapBuildMailer, self).__init__(93 super(SnapBuildMailer, self).__init__(
@@ -77,10 +105,12 @@
77 """See `BaseMailer`."""105 """See `BaseMailer`."""
78 build = self.build106 build = self.build
79 upload_job = build.store_upload_jobs.first()107 upload_job = build.store_upload_jobs.first()
80 if upload_job is None or upload_job.error_message is None:108 if upload_job is None:
81 error_message = ""109 error_message = ""
110 store_url = ""
82 else:111 else:
83 error_message = upload_job.error_message112 error_message = upload_job.error_message or ""
113 store_url = upload_job.store_url or ""
84 params = super(SnapBuildMailer, self)._getTemplateParams(114 params = super(SnapBuildMailer, self)._getTemplateParams(
85 email, recipient)115 email, recipient)
86 params.update({116 params.update({
@@ -100,6 +130,7 @@
100 "snap_authorize_url": canonical_url(130 "snap_authorize_url": canonical_url(
101 build.snap, view_name="+authorize"),131 build.snap, view_name="+authorize"),
102 "store_error_message": error_message,132 "store_error_message": error_message,
133 "store_url": store_url,
103 })134 })
104 if build.duration is not None:135 if build.duration is not None:
105 duration_formatter = DurationFormatterAPI(build.duration)136 duration_formatter = DurationFormatterAPI(build.duration)
106137
=== modified file 'lib/lp/snappy/model/snapbuildjob.py'
--- lib/lp/snappy/model/snapbuildjob.py 2016-06-21 14:51:06 +0000
+++ lib/lp/snappy/model/snapbuildjob.py 2016-06-30 17:00:36 +0000
@@ -50,8 +50,10 @@
50 ISnapStoreUploadJobSource,50 ISnapStoreUploadJobSource,
51 )51 )
52from lp.snappy.interfaces.snapstoreclient import (52from lp.snappy.interfaces.snapstoreclient import (
53 BadReleaseResponse,
53 BadScanStatusResponse,54 BadScanStatusResponse,
54 ISnapStoreClient,55 ISnapStoreClient,
56 ReleaseFailedResponse,
55 ScanFailedResponse,57 ScanFailedResponse,
56 UnauthorizedUploadResponse,58 UnauthorizedUploadResponse,
57 UploadNotScannedYetResponse,59 UploadNotScannedYetResponse,
@@ -157,6 +159,10 @@
157 return oops_vars159 return oops_vars
158160
159161
162class ManualReview(Exception):
163 pass
164
165
160@implementer(ISnapStoreUploadJob)166@implementer(ISnapStoreUploadJob)
161@provider(ISnapStoreUploadJobSource)167@provider(ISnapStoreUploadJobSource)
162class SnapStoreUploadJob(SnapBuildJobDerived):168class SnapStoreUploadJob(SnapBuildJobDerived):
@@ -164,7 +170,12 @@
164170
165 class_job_type = SnapBuildJobType.STORE_UPLOAD171 class_job_type = SnapBuildJobType.STORE_UPLOAD
166172
167 user_error_types = (UnauthorizedUploadResponse, ScanFailedResponse)173 user_error_types = (
174 UnauthorizedUploadResponse,
175 ScanFailedResponse,
176 ManualReview,
177 ReleaseFailedResponse,
178 )
168179
169 # XXX cjwatson 2016-05-04: identify transient upload failures and retry180 # XXX cjwatson 2016-05-04: identify transient upload failures and retry
170 retry_error_types = (UploadNotScannedYetResponse,)181 retry_error_types = (UploadNotScannedYetResponse,)
@@ -207,14 +218,19 @@
207 try:218 try:
208 if "status_url" not in self.metadata:219 if "status_url" not in self.metadata:
209 self.metadata["status_url"] = client.upload(self.snapbuild)220 self.metadata["status_url"] = client.upload(self.snapbuild)
210 self.store_url = client.checkStatus(self.metadata["status_url"])221 if self.store_url is None:
222 self.store_url, self.metadata["store_revision"] = (
223 client.checkStatus(self.metadata["status_url"]))
224 if self.snapbuild.snap.store_channels:
225 if self.metadata["store_revision"] is None:
226 raise ManualReview(
227 "Package held for manual review on the store; "
228 "cannot release it automatically.")
229 client.release(self.snapbuild, self.metadata["store_revision"])
211 self.error_message = None230 self.error_message = None
212 except self.retry_error_types:231 except self.retry_error_types:
213 raise232 raise
214 except Exception as e:233 except Exception as e:
215 # Abort work done so far, but make sure that we commit the error
216 # message.
217 transaction.abort()
218 self.error_message = str(e)234 self.error_message = str(e)
219 if isinstance(e, UnauthorizedUploadResponse):235 if isinstance(e, UnauthorizedUploadResponse):
220 mailer = SnapBuildMailer.forUnauthorizedUpload(self.snapbuild)236 mailer = SnapBuildMailer.forUnauthorizedUpload(self.snapbuild)
@@ -222,5 +238,14 @@
222 elif isinstance(e, (BadScanStatusResponse, ScanFailedResponse)):238 elif isinstance(e, (BadScanStatusResponse, ScanFailedResponse)):
223 mailer = SnapBuildMailer.forUploadScanFailure(self.snapbuild)239 mailer = SnapBuildMailer.forUploadScanFailure(self.snapbuild)
224 mailer.sendAll()240 mailer.sendAll()
241 elif isinstance(e, ManualReview):
242 mailer = SnapBuildMailer.forManualReview(self.snapbuild)
243 mailer.sendAll()
244 elif isinstance(e, (BadReleaseResponse, ReleaseFailedResponse)):
245 mailer = SnapBuildMailer.forReleaseFailure(self.snapbuild)
246 mailer.sendAll()
247 # The normal job infrastructure will abort the transaction, but
248 # we want to commit instead: the only database changes we make
249 # are to this job's metadata and should be preserved.
225 transaction.commit()250 transaction.commit()
226 raise251 raise
227252
=== modified file 'lib/lp/snappy/tests/test_snapbuildjob.py'
--- lib/lp/snappy/tests/test_snapbuildjob.py 2016-06-21 14:51:06 +0000
+++ lib/lp/snappy/tests/test_snapbuildjob.py 2016-06-30 17:00:36 +0000
@@ -20,6 +20,7 @@
20 ISnapStoreUploadJob,20 ISnapStoreUploadJob,
21 )21 )
22from lp.snappy.interfaces.snapstoreclient import (22from lp.snappy.interfaces.snapstoreclient import (
23 BadReleaseResponse,
23 BadScanStatusResponse,24 BadScanStatusResponse,
24 ISnapStoreClient,25 ISnapStoreClient,
25 UnauthorizedUploadResponse,26 UnauthorizedUploadResponse,
@@ -47,6 +48,7 @@
47 def __init__(self):48 def __init__(self):
48 self.upload = FakeMethod()49 self.upload = FakeMethod()
49 self.checkStatus = FakeMethod()50 self.checkStatus = FakeMethod()
51 self.release = FakeMethod()
5052
5153
52class TestSnapBuildJob(TestCaseWithFactory):54class TestSnapBuildJob(TestCaseWithFactory):
@@ -95,12 +97,13 @@
95 job = SnapStoreUploadJob.create(snapbuild)97 job = SnapStoreUploadJob.create(snapbuild)
96 client = FakeSnapStoreClient()98 client = FakeSnapStoreClient()
97 client.upload.result = self.status_url99 client.upload.result = self.status_url
98 client.checkStatus.result = self.store_url100 client.checkStatus.result = (self.store_url, 1)
99 self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))101 self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
100 with dbuser(config.ISnapStoreUploadJobSource.dbuser):102 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
101 JobRunner([job]).runAll()103 JobRunner([job]).runAll()
102 self.assertEqual([((snapbuild,), {})], client.upload.calls)104 self.assertEqual([((snapbuild,), {})], client.upload.calls)
103 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)105 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
106 self.assertEqual([], client.release.calls)
104 self.assertContentEqual([job], snapbuild.store_upload_jobs)107 self.assertContentEqual([job], snapbuild.store_upload_jobs)
105 self.assertEqual(self.store_url, job.store_url)108 self.assertEqual(self.store_url, job.store_url)
106 self.assertIsNone(job.error_message)109 self.assertIsNone(job.error_message)
@@ -118,6 +121,7 @@
118 JobRunner([job]).runAll()121 JobRunner([job]).runAll()
119 self.assertEqual([((snapbuild,), {})], client.upload.calls)122 self.assertEqual([((snapbuild,), {})], client.upload.calls)
120 self.assertEqual([], client.checkStatus.calls)123 self.assertEqual([], client.checkStatus.calls)
124 self.assertEqual([], client.release.calls)
121 self.assertContentEqual([job], snapbuild.store_upload_jobs)125 self.assertContentEqual([job], snapbuild.store_upload_jobs)
122 self.assertIsNone(job.store_url)126 self.assertIsNone(job.store_url)
123 self.assertEqual("An upload failure", job.error_message)127 self.assertEqual("An upload failure", job.error_message)
@@ -138,6 +142,7 @@
138 JobRunner([job]).runAll()142 JobRunner([job]).runAll()
139 self.assertEqual([((snapbuild,), {})], client.upload.calls)143 self.assertEqual([((snapbuild,), {})], client.upload.calls)
140 self.assertEqual([], client.checkStatus.calls)144 self.assertEqual([], client.checkStatus.calls)
145 self.assertEqual([], client.release.calls)
141 self.assertContentEqual([job], snapbuild.store_upload_jobs)146 self.assertContentEqual([job], snapbuild.store_upload_jobs)
142 self.assertIsNone(job.store_url)147 self.assertIsNone(job.store_url)
143 self.assertEqual("Authorization failed.", job.error_message)148 self.assertEqual("Authorization failed.", job.error_message)
@@ -178,6 +183,7 @@
178 JobRunner([job]).runAll()183 JobRunner([job]).runAll()
179 self.assertEqual([((snapbuild,), {})], client.upload.calls)184 self.assertEqual([((snapbuild,), {})], client.upload.calls)
180 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)185 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
186 self.assertEqual([], client.release.calls)
181 self.assertContentEqual([job], snapbuild.store_upload_jobs)187 self.assertContentEqual([job], snapbuild.store_upload_jobs)
182 self.assertIsNone(job.store_url)188 self.assertIsNone(job.store_url)
183 self.assertIsNone(job.error_message)189 self.assertIsNone(job.error_message)
@@ -190,11 +196,12 @@
190 client.upload.calls = []196 client.upload.calls = []
191 client.checkStatus.calls = []197 client.checkStatus.calls = []
192 client.checkStatus.failure = None198 client.checkStatus.failure = None
193 client.checkStatus.result = self.store_url199 client.checkStatus.result = (self.store_url, 1)
194 with dbuser(config.ISnapStoreUploadJobSource.dbuser):200 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
195 JobRunner([job]).runAll()201 JobRunner([job]).runAll()
196 self.assertEqual([], client.upload.calls)202 self.assertEqual([], client.upload.calls)
197 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)203 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
204 self.assertEqual([], client.release.calls)
198 self.assertContentEqual([job], snapbuild.store_upload_jobs)205 self.assertContentEqual([job], snapbuild.store_upload_jobs)
199 self.assertEqual(self.store_url, job.store_url)206 self.assertEqual(self.store_url, job.store_url)
200 self.assertIsNone(job.error_message)207 self.assertIsNone(job.error_message)
@@ -216,6 +223,7 @@
216 JobRunner([job]).runAll()223 JobRunner([job]).runAll()
217 self.assertEqual([((snapbuild,), {})], client.upload.calls)224 self.assertEqual([((snapbuild,), {})], client.upload.calls)
218 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)225 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
226 self.assertEqual([], client.release.calls)
219 self.assertContentEqual([job], snapbuild.store_upload_jobs)227 self.assertContentEqual([job], snapbuild.store_upload_jobs)
220 self.assertIsNone(job.store_url)228 self.assertIsNone(job.store_url)
221 self.assertEqual("Scan failed.", job.error_message)229 self.assertEqual("Scan failed.", job.error_message)
@@ -239,3 +247,114 @@
239 self.assertEqual(247 self.assertEqual(
240 "http://launchpad.dev/~requester/+snap/test-snap/+build/%d\n"248 "http://launchpad.dev/~requester/+snap/test-snap/+build/%d\n"
241 "You are the requester of the build.\n" % snapbuild.id, footer)249 "You are the requester of the build.\n" % snapbuild.id, footer)
250
251 def test_run_release(self):
252 # A run configured to automatically release the package to certain
253 # channels does so.
254 snapbuild = self.factory.makeSnapBuild(
255 store_channels=["stable", "edge"])
256 self.assertContentEqual([], snapbuild.store_upload_jobs)
257 job = SnapStoreUploadJob.create(snapbuild)
258 client = FakeSnapStoreClient()
259 client.upload.result = self.status_url
260 client.checkStatus.result = (self.store_url, 1)
261 self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
262 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
263 JobRunner([job]).runAll()
264 self.assertEqual([((snapbuild,), {})], client.upload.calls)
265 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
266 self.assertEqual([((snapbuild, 1), {})], client.release.calls)
267 self.assertContentEqual([job], snapbuild.store_upload_jobs)
268 self.assertEqual(self.store_url, job.store_url)
269 self.assertIsNone(job.error_message)
270 self.assertEqual([], pop_notifications())
271
272 def test_run_release_manual_review_notifies(self):
273 # A run configured to automatically release the package to certain
274 # channels but that encounters the manual review state on upload
275 # sends mail.
276 requester = self.factory.makePerson(name="requester")
277 snapbuild = self.factory.makeSnapBuild(
278 requester=requester, name="test-snap", owner=requester,
279 store_channels=["stable", "edge"])
280 self.assertContentEqual([], snapbuild.store_upload_jobs)
281 job = SnapStoreUploadJob.create(snapbuild)
282 client = FakeSnapStoreClient()
283 client.upload.result = self.status_url
284 client.checkStatus.result = (self.store_url, None)
285 self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
286 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
287 JobRunner([job]).runAll()
288 self.assertEqual([((snapbuild,), {})], client.upload.calls)
289 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
290 self.assertEqual([], client.release.calls)
291 self.assertContentEqual([job], snapbuild.store_upload_jobs)
292 self.assertEqual(self.store_url, job.store_url)
293 self.assertEqual(
294 "Package held for manual review on the store; "
295 "cannot release it automatically.",
296 job.error_message)
297 [notification] = pop_notifications()
298 self.assertEqual(
299 config.canonical.noreply_from_address, notification["From"])
300 self.assertEqual(
301 "Requester <%s>" % requester.preferredemail.email,
302 notification["To"])
303 subject = notification["Subject"].replace("\n ", " ")
304 self.assertEqual("test-snap held for manual review", subject)
305 self.assertEqual(
306 "Requester", notification["X-Launchpad-Message-Rationale"])
307 self.assertEqual(
308 requester.name, notification["X-Launchpad-Message-For"])
309 self.assertEqual(
310 "snap-build-release-manual-review",
311 notification["X-Launchpad-Notification-Type"])
312 body, footer = notification.get_payload(decode=True).split("\n-- \n")
313 self.assertIn(self.store_url, body)
314 self.assertEqual(
315 "http://launchpad.dev/~requester/+snap/test-snap/+build/%d\n"
316 "You are the requester of the build.\n" % snapbuild.id, footer)
317
318 def test_run_release_failure_notifies(self):
319 # A run configured to automatically release the package to certain
320 # channels but that fails to do so sends mail.
321 requester = self.factory.makePerson(name="requester")
322 snapbuild = self.factory.makeSnapBuild(
323 requester=requester, name="test-snap", owner=requester,
324 store_channels=["stable", "edge"])
325 self.assertContentEqual([], snapbuild.store_upload_jobs)
326 job = SnapStoreUploadJob.create(snapbuild)
327 client = FakeSnapStoreClient()
328 client.upload.result = self.status_url
329 client.checkStatus.result = (self.store_url, 1)
330 client.release.failure = BadReleaseResponse("Failed to publish")
331 self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
332 with dbuser(config.ISnapStoreUploadJobSource.dbuser):
333 JobRunner([job]).runAll()
334 self.assertEqual([((snapbuild,), {})], client.upload.calls)
335 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
336 self.assertEqual([((snapbuild, 1), {})], client.release.calls)
337 self.assertContentEqual([job], snapbuild.store_upload_jobs)
338 self.assertEqual(self.store_url, job.store_url)
339 self.assertEqual("Failed to publish", job.error_message)
340 [notification] = pop_notifications()
341 self.assertEqual(
342 config.canonical.noreply_from_address, notification["From"])
343 self.assertEqual(
344 "Requester <%s>" % requester.preferredemail.email,
345 notification["To"])
346 subject = notification["Subject"].replace("\n ", " ")
347 self.assertEqual("Store release failed for test-snap", subject)
348 self.assertEqual(
349 "Requester", notification["X-Launchpad-Message-Rationale"])
350 self.assertEqual(
351 requester.name, notification["X-Launchpad-Message-For"])
352 self.assertEqual(
353 "snap-build-release-failed",
354 notification["X-Launchpad-Notification-Type"])
355 body, footer = notification.get_payload(decode=True).split("\n-- \n")
356 self.assertIn("Failed to publish", body)
357 self.assertIn(self.store_url, body)
358 self.assertEqual(
359 "http://launchpad.dev/~requester/+snap/test-snap/+build/%d\n"
360 "You are the requester of the build.\n" % snapbuild.id, footer)