Merge lp:~wgrant/launchpad/recipe-cancel into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17733
Proposed branch: lp:~wgrant/launchpad/recipe-cancel
Merge into: lp:launchpad
Diff against target: 438 lines (+122/-77)
8 files modified
lib/lp/code/browser/configure.zcml (+1/-1)
lib/lp/code/browser/sourcepackagerecipebuild.py (+8/-19)
lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py (+18/-30)
lib/lp/code/configure.zcml (+6/-1)
lib/lp/code/interfaces/sourcepackagerecipebuild.py (+44/-9)
lib/lp/code/model/sourcepackagerecipebuild.py (+25/-5)
lib/lp/code/model/tests/test_sourcepackagerecipe.py (+2/-1)
lib/lp/code/model/tests/test_sourcepackagerecipebuild.py (+18/-11)
To merge this branch: bzr merge lp:~wgrant/launchpad/recipe-cancel
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+270810@code.launchpad.net

Commit message

Allow archive owners to cancel their recipe builds, and make recipes use the normal cancellation infrastructure.

Description of the change

Allow archive owners to cancel their recipe builds, and make recipes use the normal cancellation infrastructure rather than just setting themselves to SUPERSEDED.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Very nice, thanks.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/configure.zcml'
2--- lib/lp/code/browser/configure.zcml 2015-09-10 11:20:58 +0000
3+++ lib/lp/code/browser/configure.zcml 2015-09-11 14:23:48 +0000
4@@ -1176,7 +1176,7 @@
5 class="lp.code.browser.sourcepackagerecipebuild.SourcePackageRecipeBuildCancelView"
6 name="+cancel"
7 template="../../app/templates/generic-edit.pt"
8- permission="launchpad.Admin"/>
9+ permission="launchpad.Edit"/>
10 <browser:page
11 for="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuild"
12 class="lp.code.browser.sourcepackagerecipebuild.SourcePackageRecipeBuildRescoreView"
13
14=== modified file 'lib/lp/code/browser/sourcepackagerecipebuild.py'
15--- lib/lp/code/browser/sourcepackagerecipebuild.py 2013-11-18 07:51:18 +0000
16+++ lib/lp/code/browser/sourcepackagerecipebuild.py 2015-09-11 14:23:48 +0000
17@@ -39,13 +39,6 @@
18 )
19
20
21-UNEDITABLE_BUILD_STATES = (
22- BuildStatus.FULLYBUILT,
23- BuildStatus.FAILEDTOBUILD,
24- BuildStatus.SUPERSEDED,
25- BuildStatus.FAILEDTOUPLOAD,)
26-
27-
28 class SourcePackageRecipeBuildNavigation(Navigation, FileNavigationMixin):
29
30 usedfor = ISourcePackageRecipeBuild
31@@ -60,21 +53,17 @@
32
33 links = ('cancel', 'rescore')
34
35- @enabled_with_permission('launchpad.Admin')
36+ @enabled_with_permission('launchpad.Edit')
37 def cancel(self):
38- if self.context.status in UNEDITABLE_BUILD_STATES:
39- enabled = False
40- else:
41- enabled = True
42- return Link('+cancel', 'Cancel build', icon='remove', enabled=enabled)
43+ return Link(
44+ '+cancel', 'Cancel build', icon='remove',
45+ enabled=self.context.can_be_cancelled)
46
47 @enabled_with_permission('launchpad.Admin')
48 def rescore(self):
49- if self.context.status in UNEDITABLE_BUILD_STATES:
50- enabled = False
51- else:
52- enabled = True
53- return Link('+rescore', 'Rescore build', icon='edit', enabled=enabled)
54+ return Link(
55+ '+rescore', 'Rescore build', icon='edit',
56+ enabled=self.context.can_be_rescored)
57
58
59 class SourcePackageRecipeBuildView(LaunchpadView):
60@@ -152,7 +141,7 @@
61 @action('Cancel build', name='cancel')
62 def request_action(self, action, data):
63 """Cancel the build."""
64- self.context.cancelBuild()
65+ self.context.cancel()
66
67
68 class SourcePackageRecipeBuildRescoreView(LaunchpadFormView):
69
70=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py'
71--- lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py 2015-08-03 12:59:18 +0000
72+++ lib/lp/code/browser/tests/test_sourcepackagerecipebuild.py 2015-09-11 14:23:48 +0000
73@@ -22,7 +22,6 @@
74 BrowserTestCase,
75 login,
76 logout,
77- person_logged_in,
78 TestCaseWithFactory,
79 )
80 from lp.testing.layers import DatabaseFunctionalLayer
81@@ -83,17 +82,19 @@
82 daily_build_archive=self.ppa)
83 build = self.factory.makeSourcePackageRecipeBuild(
84 recipe=recipe)
85+ build.queueBuild()
86 return build
87
88 def test_cancel_build(self):
89- """An admin can cancel a build."""
90+ """The archive owner can cancel a build."""
91 queue = self.factory.makeSourcePackageRecipeBuild().queueBuild()
92 build = queue.specific_build
93 transaction.commit()
94 build_url = canonical_url(build)
95+ owner = build.archive.owner
96 logout()
97
98- browser = self.getUserBrowser(build_url, user=self.admin)
99+ browser = self.getUserBrowser(build_url, user=owner)
100 browser.getLink('Cancel build').click()
101
102 self.assertEqual(
103@@ -107,12 +108,10 @@
104 build_url)
105
106 login(ANONYMOUS)
107- self.assertEqual(
108- BuildStatus.SUPERSEDED,
109- build.status)
110+ self.assertEqual(BuildStatus.CANCELLED, build.status)
111
112- def test_cancel_build_not_admin(self):
113- """No one but an admin can cancel a build."""
114+ def test_cancel_build_not_owner(self):
115+ """A normal user can't cancel a build."""
116 queue = self.factory.makeSourcePackageRecipeBuild().queueBuild()
117 build = queue.specific_build
118 transaction.commit()
119@@ -131,12 +130,14 @@
120 def test_cancel_build_wrong_state(self):
121 """If the build isn't queued, you can't cancel it."""
122 build = self.makeRecipeBuild()
123- build.cancelBuild()
124+ with admin_logged_in():
125+ build.cancel()
126 transaction.commit()
127 build_url = canonical_url(build)
128+ owner = build.archive.owner
129 logout()
130
131- browser = self.getUserBrowser(build_url, user=self.admin)
132+ browser = self.getUserBrowser(build_url, user=owner)
133 self.assertRaises(
134 LinkNotFoundError,
135 browser.getLink, 'Cancel build')
136@@ -212,7 +213,8 @@
137 def test_rescore_build_wrong_state(self):
138 """If the build isn't queued, you can't rescore it."""
139 build = self.makeRecipeBuild()
140- build.cancelBuild()
141+ with admin_logged_in():
142+ build.cancel()
143 transaction.commit()
144 build_url = canonical_url(build)
145 logout()
146@@ -228,25 +230,11 @@
147 This is the case where the user has a stale link that they click on.
148 """
149 build = self.factory.makeSourcePackageRecipeBuild()
150- build.cancelBuild()
151- index_url = canonical_url(build)
152- browser = self.getViewBrowser(build, '+rescore', user=self.admin)
153- self.assertEqual(index_url, browser.url)
154- self.assertIn(
155- 'Cannot rescore this build because it is not queued.',
156- browser.contents)
157-
158- def test_rescore_build_wrong_state_stale_page(self):
159- """Show sane error if you attempt to rescore a non-queued build.
160-
161- This is the case where the user is on the rescore page and submits.
162- """
163- build = self.factory.makeSourcePackageRecipeBuild()
164- index_url = canonical_url(build)
165- browser = self.getViewBrowser(build, '+rescore', user=self.admin)
166- with person_logged_in(self.admin):
167- build.cancelBuild()
168- browser.getLink('Rescore build').click()
169+ build.queueBuild()
170+ with admin_logged_in():
171+ build.cancel()
172+ index_url = canonical_url(build)
173+ browser = self.getViewBrowser(build, '+rescore', user=self.admin)
174 self.assertEqual(index_url, browser.url)
175 self.assertIn(
176 'Cannot rescore this build because it is not queued.',
177
178=== modified file 'lib/lp/code/configure.zcml'
179--- lib/lp/code/configure.zcml 2015-09-01 17:10:46 +0000
180+++ lib/lp/code/configure.zcml 2015-09-11 14:23:48 +0000
181@@ -1015,7 +1015,12 @@
182
183 <class
184 class="lp.code.model.sourcepackagerecipebuild.SourcePackageRecipeBuild">
185- <require permission="launchpad.View" interface="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuild"/>
186+ <require
187+ permission="launchpad.View"
188+ interface="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuildView"/>
189+ <require
190+ permission="launchpad.Edit"
191+ interface="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuildEdit"/>
192 </class>
193
194 <securedutility
195
196=== modified file 'lib/lp/code/interfaces/sourcepackagerecipebuild.py'
197--- lib/lp/code/interfaces/sourcepackagerecipebuild.py 2015-05-14 08:50:41 +0000
198+++ lib/lp/code/interfaces/sourcepackagerecipebuild.py 2015-09-11 14:23:48 +0000
199@@ -9,11 +9,17 @@
200 'ISourcePackageRecipeBuildSource',
201 ]
202
203-from lazr.restful.declarations import export_as_webservice_entry
204+from lazr.restful.declarations import (
205+ export_as_webservice_entry,
206+ export_write_operation,
207+ exported,
208+ operation_for_version,
209+ )
210 from lazr.restful.fields import (
211 CollectionField,
212 Reference,
213 )
214+from zope.interface import Interface
215 from zope.schema import (
216 Bool,
217 Int,
218@@ -21,9 +27,7 @@
219 )
220
221 from lp import _
222-from lp.buildmaster.interfaces.buildfarmjob import (
223- ISpecificBuildFarmJobSource,
224- )
225+from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
226 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
227 from lp.code.interfaces.sourcepackagerecipe import (
228 ISourcePackageRecipe,
229@@ -35,9 +39,7 @@
230 from lp.soyuz.interfaces.sourcepackagerelease import ISourcePackageRelease
231
232
233-class ISourcePackageRecipeBuild(IPackageBuild):
234- """A build of a source package."""
235- export_as_webservice_entry()
236+class ISourcePackageRecipeBuildView(IPackageBuild):
237
238 id = Int(title=_("Identifier for this build."))
239
240@@ -56,6 +58,16 @@
241 recipe = Object(
242 schema=ISourcePackageRecipe, title=_("The recipe being built."))
243
244+ can_be_rescored = exported(Bool(
245+ title=_("Can be rescored"),
246+ required=True, readonly=True,
247+ description=_("Whether this build record can be rescored manually.")))
248+
249+ can_be_cancelled = exported(Bool(
250+ title=_("Can be cancelled"),
251+ required=True, readonly=True,
252+ description=_("Whether this build record can be cancelled.")))
253+
254 manifest = Object(
255 schema=ISourcePackageRecipeData, title=_(
256 'A snapshot of the recipe for this build.'))
257@@ -70,13 +82,36 @@
258 def getFileByName(filename):
259 """Return the file under +files with specified name."""
260
261- def cancelBuild():
262- """Cancel the build."""
263+
264+class ISourcePackageRecipeBuildEdit(Interface):
265+
266+ @export_write_operation()
267+ @operation_for_version("devel")
268+ def cancel():
269+ """Cancel the build if it is either pending or in progress.
270+
271+ Check the can_be_cancelled property prior to calling this method to
272+ find out if cancelling the build is possible.
273+
274+ If the build is in progress, it is marked as CANCELLING until the
275+ buildd manager terminates the build and marks it CANCELLED. If the
276+ build is not in progress, it is marked CANCELLED immediately and is
277+ removed from the build queue.
278+
279+ If the build is not in a cancellable state, this method is a no-op.
280+ """
281
282 def destroySelf():
283 """Delete the build itself."""
284
285
286+class ISourcePackageRecipeBuild(ISourcePackageRecipeBuildView,
287+ ISourcePackageRecipeBuildEdit):
288+ """A build of a source package."""
289+
290+ export_as_webservice_entry()
291+
292+
293 class ISourcePackageRecipeBuildSource(ISpecificBuildFarmJobSource):
294 """A utility of this interface be used to create source package builds."""
295
296
297=== modified file 'lib/lp/code/model/sourcepackagerecipebuild.py'
298--- lib/lp/code/model/sourcepackagerecipebuild.py 2015-09-11 13:28:31 +0000
299+++ lib/lp/code/model/sourcepackagerecipebuild.py 2015-09-11 14:23:48 +0000
300@@ -259,11 +259,31 @@
301 builds.append(build)
302 return builds
303
304- def cancelBuild(self):
305- """See `ISourcePackageRecipeBuild.`"""
306- self.updateStatus(BuildStatus.SUPERSEDED)
307- if self.buildqueue_record is not None:
308- self.buildqueue_record.destroySelf()
309+ @property
310+ def can_be_rescored(self):
311+ """See `IBuild`."""
312+ return self.status is BuildStatus.NEEDSBUILD
313+
314+ @property
315+ def can_be_cancelled(self):
316+ """See `ISourcePackageRecipeBuild`."""
317+ if not self.buildqueue_record:
318+ return False
319+
320+ cancellable_statuses = [
321+ BuildStatus.BUILDING,
322+ BuildStatus.NEEDSBUILD,
323+ ]
324+ return self.status in cancellable_statuses
325+
326+ def cancel(self):
327+ """See `ISourcePackageRecipeBuild`."""
328+ if not self.can_be_cancelled:
329+ return
330+ # BuildQueue.cancel() will decide whether to go straight to
331+ # CANCELLED, or go through CANCELLING to let buildd-manager
332+ # clean up the slave.
333+ self.buildqueue_record.cancel()
334
335 def destroySelf(self):
336 if self.buildqueue_record is not None:
337
338=== modified file 'lib/lp/code/model/tests/test_sourcepackagerecipe.py'
339--- lib/lp/code/model/tests/test_sourcepackagerecipe.py 2015-04-30 01:45:30 +0000
340+++ lib/lp/code/model/tests/test_sourcepackagerecipe.py 2015-09-11 14:23:48 +0000
341@@ -666,7 +666,8 @@
342 # Cancelled builds are not considered pending.
343 recipe = self.factory.makeSourcePackageRecipe()
344 build = self.factory.makeSourcePackageRecipeBuild(recipe=recipe)
345- build.cancelBuild()
346+ build.queueBuild()
347+ build.cancel()
348 self.assertEqual([build], list(recipe.builds))
349 self.assertEqual([build], list(recipe.completed_builds))
350 self.assertEqual([], list(recipe.pending_builds))
351
352=== modified file 'lib/lp/code/model/tests/test_sourcepackagerecipebuild.py'
353--- lib/lp/code/model/tests/test_sourcepackagerecipebuild.py 2015-09-11 06:04:36 +0000
354+++ lib/lp/code/model/tests/test_sourcepackagerecipebuild.py 2015-09-11 14:23:48 +0000
355@@ -38,6 +38,7 @@
356 from lp.services.mail.sendmail import format_address
357 from lp.services.webapp.authorization import check_permission
358 from lp.testing import (
359+ admin_logged_in,
360 ANONYMOUS,
361 login,
362 person_logged_in,
363@@ -78,7 +79,8 @@
364 # SourcePackageRecipeBuild provides IPackageBuild and
365 # ISourcePackageRecipeBuild.
366 spb = self.makeSourcePackageRecipeBuild()
367- self.assertProvides(spb, ISourcePackageRecipeBuild)
368+ with admin_logged_in():
369+ self.assertProvides(spb, ISourcePackageRecipeBuild)
370
371 def test_implements_interface(self):
372 build = self.makeSourcePackageRecipeBuild()
373@@ -88,7 +90,8 @@
374 # A source package recipe build can be stored in the database
375 spb = self.makeSourcePackageRecipeBuild()
376 transaction.commit()
377- self.assertProvides(spb, ISourcePackageRecipeBuild)
378+ with admin_logged_in():
379+ self.assertProvides(spb, ISourcePackageRecipeBuild)
380
381 def test_queueBuild(self):
382 spb = self.makeSourcePackageRecipeBuild()
383@@ -411,7 +414,8 @@
384 # ISourcePackageRecipeBuild should make sure to remove jobs and build
385 # queue entries and then invalidate itself.
386 build = self.factory.makeSourcePackageRecipeBuild()
387- build.destroySelf()
388+ with admin_logged_in():
389+ build.destroySelf()
390
391 def test_destroySelf_clears_release(self):
392 # Destroying a sourcepackagerecipebuild removes references to it from
393@@ -420,7 +424,8 @@
394 release = self.factory.makeSourcePackageRelease(
395 source_package_recipe_build=build)
396 self.assertEqual(build, release.source_package_recipe_build)
397- build.destroySelf()
398+ with admin_logged_in():
399+ build.destroySelf()
400 self.assertIs(None, release.source_package_recipe_build)
401 transaction.commit()
402
403@@ -433,18 +438,19 @@
404 # Ensure database ids are set.
405 store.flush()
406 build_farm_job_id = naked_build.build_farm_job_id
407- build.destroySelf()
408+ with admin_logged_in():
409+ build.destroySelf()
410 self.assertIs(None, store.get(BuildFarmJob, build_farm_job_id))
411
412- def test_cancelBuild(self):
413+ def test_cancel(self):
414 # ISourcePackageRecipeBuild should make sure to remove jobs and build
415 # queue entries and then invalidate itself.
416 build = self.factory.makeSourcePackageRecipeBuild()
417- build.cancelBuild()
418+ build.queueBuild()
419+ with admin_logged_in():
420+ build.cancel()
421
422- self.assertEqual(
423- BuildStatus.SUPERSEDED,
424- build.status)
425+ self.assertEqual(BuildStatus.CANCELLED, build.status)
426
427 def test_getUploader(self):
428 # For ACL purposes the uploader is the build requester.
429@@ -524,7 +530,8 @@
430 build = self.factory.makeSourcePackageRecipeBuild(
431 recipe=cake, distroseries=secret, archive=pantry)
432 build.updateStatus(BuildStatus.FULLYBUILT)
433- cake.destroySelf()
434+ with admin_logged_in():
435+ cake.destroySelf()
436 IStore(build).flush()
437 build.notify()
438 notifications = pop_notifications()