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

Proposed by Colin Watson on 2016-06-30
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 on 2016-07-11
Launchpad code reviewers 2016-06-30 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.

+1

review: Approve

Preview Diff

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