launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24585
[Merge] ~cjwatson/launchpad:oci-recipe-build-cancel-rescore into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-build-cancel-rescore into launchpad:master with ~cjwatson/launchpad:fix-oci-recipe-build-ui as a prerequisite.
Commit message:
Implement cancel and rescore for OCI recipe builds
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1847444 in Launchpad itself: "Support OCI image building"
https://bugs.launchpad.net/launchpad/+bug/1847444
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/382098
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-build-cancel-rescore into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index 941cccc..3fbb0f1 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -69,6 +69,9 @@
for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
path_expression="string:+build/${id}"
attribute_to_parent="recipe" />
+ <browser:menus
+ module="lp.oci.browser.ocirecipebuild"
+ classes="OCIRecipeBuildContextMenu" />
<browser:navigation
module="lp.oci.browser.ocirecipebuild"
classes="OCIRecipeBuildNavigation" />
@@ -81,6 +84,18 @@
permission="launchpad.View"
name="+index"
template="../templates/ocirecipebuild-index.pt" />
+ <browser:page
+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+ class="lp.oci.browser.ocirecipebuild.OCIRecipeBuildCancelView"
+ permission="launchpad.Edit"
+ name="+cancel"
+ template="../../app/templates/generic-edit.pt" />
+ <browser:page
+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+ class="lp.oci.browser.ocirecipebuild.OCIRecipeBuildRescoreView"
+ permission="launchpad.Admin"
+ name="+rescore"
+ template="../../app/templates/generic-edit.pt" />
<adapter
provides="lp.services.webapp.interfaces.IBreadcrumb"
for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
diff --git a/lib/lp/oci/browser/ocirecipebuild.py b/lib/lp/oci/browser/ocirecipebuild.py
index 8b3a727..dfed462 100644
--- a/lib/lp/oci/browser/ocirecipebuild.py
+++ b/lib/lp/oci/browser/ocirecipebuild.py
@@ -7,18 +7,28 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
+ 'OCIRecipeBuildCancelView',
+ 'OCIRecipeBuildContextMenu',
'OCIRecipeBuildNavigation',
+ 'OCIRecipeBuildRescoreView',
'OCIRecipeBuildView',
]
from zope.interface import Interface
-from lp.app.browser.launchpadform import LaunchpadFormView
+from lp.app.browser.launchpadform import (
+ action,
+ LaunchpadFormView,
+ )
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
from lp.services.webapp import (
canonical_url,
+ ContextMenu,
+ enabled_with_permission,
+ Link,
Navigation,
)
+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
class OCIRecipeBuildNavigation(Navigation):
@@ -26,6 +36,28 @@ class OCIRecipeBuildNavigation(Navigation):
usedfor = IOCIRecipeBuild
+class OCIRecipeBuildContextMenu(ContextMenu):
+ """Context menu for OCI recipe builds."""
+
+ usedfor = IOCIRecipeBuild
+
+ facet = "overview"
+
+ links = ("cancel", "rescore")
+
+ @enabled_with_permission("launchpad.Edit")
+ def cancel(self):
+ return Link(
+ "+cancel", "Cancel build", icon="remove",
+ enabled=self.context.can_be_cancelled)
+
+ @enabled_with_permission("launchpad.Admin")
+ def rescore(self):
+ return Link(
+ "+rescore", "Rescore build", icon="edit",
+ enabled=self.context.can_be_rescored)
+
+
class OCIRecipeBuildView(LaunchpadFormView):
"""Default view of an OCIRecipeBuild."""
@@ -41,3 +73,53 @@ class OCIRecipeBuildView(LaunchpadFormView):
@property
def next_url(self):
return canonical_url(self.context)
+
+
+class OCIRecipeBuildCancelView(LaunchpadFormView):
+ """View for cancelling an OCI recipe build."""
+
+ class schema(Interface):
+ """Schema for cancelling a build."""
+
+ page_title = label = "Cancel build"
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+ next_url = cancel_url
+
+ @action("Cancel build", name="cancel")
+ def request_action(self, action, data):
+ """Cancel the build."""
+ self.context.cancel()
+
+
+class OCIRecipeBuildRescoreView(LaunchpadFormView):
+ """View for rescoring an OCI recipe build."""
+
+ schema = IBuildRescoreForm
+
+ page_title = label = "Rescore build"
+
+ def __call__(self):
+ if self.context.can_be_rescored:
+ return super(OCIRecipeBuildRescoreView, self).__call__()
+ self.request.response.addWarningNotification(
+ "Cannot rescore this build because it is not queued.")
+ self.request.response.redirect(canonical_url(self.context))
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+ next_url = cancel_url
+
+ @action("Rescore build", name="rescore")
+ def request_action(self, action, data):
+ """Rescore the build."""
+ score = data.get("priority")
+ self.context.rescore(score)
+ self.request.response.addNotification("Build rescored to %s." % score)
+
+ @property
+ def initial_values(self):
+ return {"score": str(self.context.buildqueue_record.lastscore)}
diff --git a/lib/lp/oci/browser/tests/test_ocirecipebuild.py b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
index 97afb77..9c6eae3 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
@@ -9,15 +9,25 @@ __metaclass__ = type
import re
+from fixtures import FakeLogger
+import soupmatchers
from storm.locals import Store
from testtools.matchers import StartsWith
+import transaction
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+from zope.testbrowser.browser import LinkNotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.buildmaster.enums import BuildStatus
from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
from lp.services.features.testing import FeatureFixture
from lp.services.webapp import canonical_url
from lp.testing import (
+ ANONYMOUS,
BrowserTestCase,
+ login,
+ person_logged_in,
TestCaseWithFactory,
)
from lp.testing.layers import (
@@ -27,6 +37,7 @@ from lp.testing.layers import (
from lp.testing.pages import (
extract_text,
find_main_content,
+ find_tags_by_class,
)
@@ -86,9 +97,100 @@ class TestOCIRecipeBuildOperations(BrowserTestCase):
def setUp(self):
super(TestOCIRecipeBuildOperations, self).setUp()
+ self.useFixture(FakeLogger())
self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
self.build = self.factory.makeOCIRecipeBuild()
self.build_url = canonical_url(self.build)
+ self.requester = self.build.requester
+ self.buildd_admin = self.factory.makePerson(
+ member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+
+ def test_cancel_build(self):
+ # The requester of a build can cancel it.
+ self.build.queueBuild()
+ transaction.commit()
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ browser.getLink("Cancel build").click()
+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+ browser.getControl("Cancel build").click()
+ self.assertEqual(self.build_url, browser.url)
+ login(ANONYMOUS)
+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+
+ def test_cancel_build_random_user(self):
+ # An unrelated non-admin user cannot cancel a build.
+ self.build.queueBuild()
+ transaction.commit()
+ user = self.factory.makePerson()
+ browser = self.getViewBrowser(self.build, user=user)
+ self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+ self.assertRaises(
+ Unauthorized, self.getUserBrowser, self.build_url + "/+cancel",
+ user=user)
+
+ def test_cancel_build_wrong_state(self):
+ # If the build isn't queued, you can't cancel it.
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+
+ def test_rescore_build(self):
+ # A buildd admin can rescore a build.
+ self.build.queueBuild()
+ transaction.commit()
+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+ browser.getLink("Rescore build").click()
+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+ browser.getControl("Priority").value = "1024"
+ browser.getControl("Rescore build").click()
+ self.assertEqual(self.build_url, browser.url)
+ login(ANONYMOUS)
+ self.assertEqual(1024, self.build.buildqueue_record.lastscore)
+
+ def test_rescore_build_invalid_score(self):
+ # Build scores can only take numbers.
+ self.build.queueBuild()
+ transaction.commit()
+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+ browser.getLink("Rescore build").click()
+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+ browser.getControl("Priority").value = "tentwentyfour"
+ browser.getControl("Rescore build").click()
+ self.assertEqual(
+ "Invalid integer data",
+ extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+ def test_rescore_build_not_admin(self):
+ # A non-admin user cannot cancel a build.
+ self.build.queueBuild()
+ transaction.commit()
+ user = self.factory.makePerson()
+ browser = self.getViewBrowser(self.build, user=user)
+ self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+ self.assertRaises(
+ Unauthorized, self.getUserBrowser, self.build_url + "/+rescore",
+ user=user)
+
+ def test_rescore_build_wrong_state(self):
+ # If the build isn't NEEDSBUILD, you can't rescore it.
+ self.build.queueBuild()
+ with person_logged_in(self.requester):
+ self.build.cancel()
+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+ self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+
+ def test_rescore_build_wrong_state_stale_link(self):
+ # An attempt to rescore a non-queued build from a stale link shows a
+ # sensible error message.
+ self.build.queueBuild()
+ with person_logged_in(self.requester):
+ self.build.cancel()
+ browser = self.getViewBrowser(
+ self.build, "+rescore", user=self.buildd_admin)
+ self.assertEqual(self.build_url, browser.url)
+ self.assertThat(browser.contents, soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "notification", "div", attrs={"class": "warning message"},
+ text="Cannot rescore this build because it is not queued.")))
def test_builder_history(self):
Store.of(self.build).flush()
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index a41e2c8..752e61d 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd. This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Interfaces for a build record for OCI recipes."""
@@ -17,6 +17,7 @@ from zope.interface import Interface
from zope.schema import (
Bool,
Datetime,
+ Int,
TextLine,
)
@@ -30,20 +31,8 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
-class IOCIRecipeBuildEdit(Interface):
-
- # XXX twom 2020-02-10 This will probably need cancel() implementing
-
- def addFile(lfa, layer_file_digest):
- """Add an OCI file to this build.
-
- :param lfa: An `ILibraryFileAlias`.
- :param layer_file_digest: Digest for this file, used for image layers.
- :return: An `IOCILayerFile`.
- """
-
-
class IOCIRecipeBuildView(IPackageBuild):
+ """`IOCIRecipeBuild` attributes that require launchpad.View permission."""
requester = PublicPersonChoice(
title=_("Requester"),
@@ -88,10 +77,52 @@ class IOCIRecipeBuildView(IPackageBuild):
title=_("The series and architecture for which to build."),
required=True, readonly=True)
+ score = Int(
+ title=_("Score of the related build farm job (if any)."),
+ required=False, readonly=True)
+
+ can_be_rescored = Bool(
+ title=_("Can be rescored"),
+ required=True, readonly=True,
+ description=_("Whether this build record can be rescored manually."))
+
+ can_be_cancelled = Bool(
+ title=_("Can be cancelled"),
+ required=True, readonly=True,
+ description=_("Whether this build record can be cancelled."))
+
+
+class IOCIRecipeBuildEdit(Interface):
+ """`IOCIRecipeBuild` attributes that require launchpad.Edit permission."""
+
+ def addFile(lfa, layer_file_digest):
+ """Add an OCI file to this build.
+
+ :param lfa: An `ILibraryFileAlias`.
+ :param layer_file_digest: Digest for this file, used for image layers.
+ :return: An `IOCILayerFile`.
+ """
+
+ def cancel():
+ """Cancel the build if it is either pending or in progress.
+
+ Check the can_be_cancelled property prior to calling this method to
+ find out if cancelling the build is possible.
+
+ If the build is in progress, it is marked as CANCELLING until the
+ buildd manager terminates the build and marks it CANCELLED. If the
+ build is not in progress, it is marked CANCELLED immediately and is
+ removed from the build queue.
+
+ If the build is not in a cancellable state, this method is a no-op.
+ """
+
class IOCIRecipeBuildAdmin(Interface):
- # XXX twom 2020-02-10 This will probably need rescore() implementing
- pass
+ """`IOCIRecipeBuild` attributes that require launchpad.Admin permission."""
+
+ def rescore(score):
+ """Change the build's score."""
class IOCIRecipeBuild(IOCIRecipeBuildAdmin, IOCIRecipeBuildEdit,
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index d8bd496..21c1cce 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -163,6 +163,47 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
self.recipe.oci_project.pillar.name, self.recipe.oci_project.name,
self.recipe.name)
+ @property
+ def score(self):
+ """See `IOCIRecipeBuild`."""
+ if self.buildqueue_record is None:
+ return None
+ else:
+ return self.buildqueue_record.lastscore
+
+ @property
+ def can_be_rescored(self):
+ """See `IOCIRecipeBuild`."""
+ return (
+ self.buildqueue_record is not None and
+ self.status is BuildStatus.NEEDSBUILD)
+
+ @property
+ def can_be_cancelled(self):
+ """See `IOCIRecipeBuild`."""
+ if not self.buildqueue_record:
+ return False
+
+ cancellable_statuses = [
+ BuildStatus.BUILDING,
+ BuildStatus.NEEDSBUILD,
+ ]
+ return self.status in cancellable_statuses
+
+ def rescore(self, score):
+ """See `IOCIRecipeBuild`."""
+ assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
+ self.buildqueue_record.manualScore(score)
+
+ def cancel(self):
+ """See `IOCIRecipeBuild`."""
+ if not self.can_be_cancelled:
+ return
+ # BuildQueue.cancel() will decide whether to go straight to
+ # CANCELLED, or go through CANCELLING to let buildd-manager clean up
+ # the slave.
+ self.buildqueue_record.cancel()
+
def calculateScore(self):
# XXX twom 2020-02-11 - This might need an addition?
return 2510
diff --git a/lib/lp/oci/templates/ocirecipebuild-index.pt b/lib/lp/oci/templates/ocirecipebuild-index.pt
index 04b7fcf..b9ff8c1 100644
--- a/lib/lp/oci/templates/ocirecipebuild-index.pt
+++ b/lib/lp/oci/templates/ocirecipebuild-index.pt
@@ -79,6 +79,9 @@
on <a tal:content="context/builder/title"
tal:attributes="href context/builder/fmt:url"/>
</tal:built>
+ <tal:cancel define="link context/menu:context/cancel"
+ condition="link/enabled"
+ replace="structure link/fmt:link" />
</p>
<ul>
@@ -130,6 +133,14 @@
(<span tal:replace="file/content/filesize/fmt:bytes" />)
</li>
</ul>
+
+ <div
+ style="margin-top: 1.5em"
+ tal:define="link context/menu:context/rescore"
+ tal:condition="link/enabled"
+ >
+ <a tal:replace="structure link/fmt:link"/>
+ </div>
</metal:macro>
<metal:macro define-macro="buildlog">
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index e60a362..5ae8b78 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -100,6 +100,35 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
self.build.getLayerFileByDigest,
'missing')
+ def test_can_be_cancelled(self):
+ # For all states that can be cancelled, can_be_cancelled returns True.
+ ok_cases = [
+ BuildStatus.BUILDING,
+ BuildStatus.NEEDSBUILD,
+ ]
+ for status in BuildStatus:
+ if status in ok_cases:
+ self.assertTrue(self.build.can_be_cancelled)
+ else:
+ self.assertFalse(self.build.can_be_cancelled)
+
+ def test_cancel_not_in_progress(self):
+ # The cancel() method for a pending build leaves it in the CANCELLED
+ # state.
+ self.build.queueBuild()
+ self.build.cancel()
+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+ self.assertIsNone(self.build.buildqueue_record)
+
+ def test_cancel_in_progress(self):
+ # The cancel() method for a building build leaves it in the
+ # CANCELLING state.
+ bq = self.build.queueBuild()
+ bq.markAsBuilding(self.factory.makeBuilder())
+ self.build.cancel()
+ self.assertEqual(BuildStatus.CANCELLING, self.build.status)
+ self.assertEqual(bq, self.build.buildqueue_record)
+
def test_estimateDuration(self):
# Without previous builds, the default time estimate is 30m.
self.assertEqual(1800, self.build.estimateDuration().seconds)
diff --git a/lib/lp/security.py b/lib/lp/security.py
index a44393a..683af3f 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -3514,6 +3514,27 @@ class ViewOCIRecipeBuild(AnonymousAuthorization):
usedfor = IOCIRecipeBuild
+class EditOCIRecipeBuild(AdminByBuilddAdmin):
+ permission = 'launchpad.Edit'
+ usedfor = IOCIRecipeBuild
+
+ def checkAuthenticated(self, user):
+ """Check edit access for OCI recipe builds.
+
+ Allow admins, buildd admins, and the owner of the OCI recipe.
+ (Note that the requester of the build is required to be in the team
+ that owns the OCI recipe.)
+ """
+ auth_recipe = EditOCIRecipe(self.obj.recipe)
+ if auth_recipe.checkAuthenticated(user):
+ return True
+ return super(EditOCIRecipeBuild, self).checkAuthenticated(user)
+
+
+class AdminOCIRecipeBuild(AdminByBuilddAdmin):
+ usedfor = IOCIRecipeBuild
+
+
class ViewOCIRegistryCredentials(AuthorizationBase):
permission = 'launchpad.View'
usedfor = IOCIRegistryCredentials
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 8e0f1c1..db46838 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -160,7 +160,9 @@ from lp.hardwaredb.interfaces.hwdb import (
from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
-from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentialsSet
+from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ )
from lp.oci.model.ocirecipe import OCIRecipeArch
from lp.oci.model.ocirecipebuild import OCIFile
from lp.registry.enums import (
@@ -4988,7 +4990,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
processor = self.makeProcessor()
return OCIRecipeArch(recipe, processor)
- def makeOCIRecipeBuild(self, requester=None, recipe=None,
+ def makeOCIRecipeBuild(self, requester=None, registrant=None, recipe=None,
distro_arch_series=None, date_created=DEFAULT,
status=BuildStatus.NEEDSBUILD, builder=None,
duration=None):
@@ -5009,7 +5011,10 @@ class BareLaunchpadObjectFactory(ObjectFactory):
if recipe is None:
oci_project = self.makeOCIProject(
pillar=distro_arch_series.distroseries.distribution)
- recipe = self.makeOCIRecipe(oci_project=oci_project)
+ if registrant is None:
+ registrant = requester
+ recipe = self.makeOCIRecipe(
+ registrant=registrant, oci_project=oci_project)
oci_build = getUtility(IOCIRecipeBuildSet).new(
requester, recipe, distro_arch_series, date_created)
if duration is not None: