launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25778
[Merge] ~cjwatson/launchpad:oci-recipe-retry-build into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-retry-build into launchpad:master.
Commit message:
Add support for retrying OCI recipe builds
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/394745
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-retry-build into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index 0aa6da8..6c254fb 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -103,6 +103,12 @@
template="../templates/ocirecipebuild-index.pt" />
<browser:page
for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
+ class="lp.oci.browser.ocirecipebuild.OCIRecipeBuildRetryView"
+ permission="launchpad.Edit"
+ name="+retry"
+ template="../templates/ocirecipebuild-retry.pt" />
+ <browser:page
+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
class="lp.oci.browser.ocirecipebuild.OCIRecipeBuildCancelView"
permission="launchpad.Edit"
name="+cancel"
diff --git a/lib/lp/oci/browser/ocirecipebuild.py b/lib/lp/oci/browser/ocirecipebuild.py
index b38a531..e6b4003 100644
--- a/lib/lp/oci/browser/ocirecipebuild.py
+++ b/lib/lp/oci/browser/ocirecipebuild.py
@@ -51,7 +51,13 @@ class OCIRecipeBuildContextMenu(ContextMenu):
facet = "overview"
- links = ("cancel", "rescore")
+ links = ("retry", "cancel", "rescore")
+
+ @enabled_with_permission("launchpad.Edit")
+ def retry(self):
+ return Link(
+ "+retry", "Retry this build", icon="retry",
+ enabled=self.context.can_be_retried)
@enabled_with_permission("launchpad.Edit")
def cancel(self):
@@ -109,6 +115,32 @@ class OCIRecipeBuildView(LaunchpadFormView):
"possible.")
+class OCIRecipeBuildRetryView(LaunchpadFormView):
+ """View for retrying an OCI recipe build."""
+
+ class schema(Interface):
+ """Schema for retrying a build."""
+
+ page_title = label = "Retry build"
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+ next_url = cancel_url
+
+ @action("Retry build", name="retry")
+ def request_action(self, action, data):
+ """Retry the build."""
+ if not self.context.can_be_retried:
+ self.request.response.addErrorNotification(
+ "Build cannot be retried")
+ else:
+ self.context.retry()
+ self.request.response.addInfoNotification("Build has been queued")
+
+ self.request.response.redirect(self.next_url)
+
+
class OCIRecipeBuildCancelView(LaunchpadFormView):
"""View for cancelling an OCI recipe build."""
diff --git a/lib/lp/oci/browser/tests/test_ocirecipebuild.py b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
index 625594e..25533cf 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
@@ -170,6 +170,38 @@ class TestOCIRecipeBuildOperations(BrowserTestCase):
self.buildd_admin = self.factory.makePerson(
member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+ def test_retry_build(self):
+ # The requester of a build can retry it.
+ self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+ transaction.commit()
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ browser.getLink("Retry this build").click()
+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+ browser.getControl("Retry build").click()
+ self.assertEqual(self.build_url, browser.url)
+ login(ANONYMOUS)
+ self.assertEqual(BuildStatus.NEEDSBUILD, self.build.status)
+
+ def test_retry_build_random_user(self):
+ # An unrelated non-admin user cannot retry a build.
+ self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+ transaction.commit()
+ user = self.factory.makePerson()
+ browser = self.getViewBrowser(self.build, user=user)
+ self.assertRaises(
+ LinkNotFoundError, browser.getLink, "Retry this build")
+ self.assertRaises(
+ Unauthorized, self.getUserBrowser, self.build_url + "/+retry",
+ user=user)
+
+ def test_retry_build_wrong_state(self):
+ # If the build isn't in an unsuccessful terminal state, you can't
+ # retry it.
+ self.build.updateStatus(BuildStatus.FULLYBUILT)
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ self.assertRaises(
+ LinkNotFoundError, browser.getLink, "Retry this build")
+
def test_cancel_build(self):
# The requester of a build can cancel it.
self.build.queueBuild()
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 3273271..d691ef6 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -166,6 +166,11 @@ class IOCIRecipeBuildView(IPackageBuild):
required=True, readonly=True,
description=_("Whether this build record can be rescored manually.")))
+ can_be_retried = exported(Bool(
+ title=_("Can be retried"),
+ required=True, readonly=True,
+ description=_("Whether this build record can be retried.")))
+
can_be_cancelled = exported(Bool(
title=_("Can be cancelled"),
required=True, readonly=True,
@@ -228,6 +233,15 @@ class IOCIRecipeBuildEdit(Interface):
where an upload can be scheduled.
"""
+ @export_write_operation()
+ @operation_for_version("devel")
+ def retry():
+ """Restore the build record to its initial state.
+
+ Build record loses its history, is moved to NEEDSBUILD and a new
+ non-scored BuildQueue entry is created for it.
+ """
+
def cancel():
"""Cancel the build if it is either pending or in progress.
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index eda6fa3..89d8129 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -56,6 +56,7 @@ from lp.oci.model.ocirecipebuildjob import (
OCIRecipeBuildJobType,
)
from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.series import SeriesStatus
from lp.registry.model.person import Person
from lp.services.config import config
from lp.services.database.bulk import load_related
@@ -213,6 +214,27 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
return self.buildqueue_record.lastscore
@property
+ def can_be_retried(self):
+ """See `IOCIRecipeBuild`."""
+ # First check that the behaviour would accept the build if it
+ # succeeded.
+ if self.distro_series.status == SeriesStatus.OBSOLETE:
+ return False
+
+ failed_statuses = [
+ BuildStatus.FAILEDTOBUILD,
+ BuildStatus.MANUALDEPWAIT,
+ BuildStatus.CHROOTWAIT,
+ BuildStatus.FAILEDTOUPLOAD,
+ BuildStatus.CANCELLED,
+ BuildStatus.SUPERSEDED,
+ ]
+
+ # If the build is currently in any of the failed states,
+ # it may be retried.
+ return self.status in failed_statuses
+
+ @property
def can_be_rescored(self):
"""See `IOCIRecipeBuild`."""
return (
@@ -231,6 +253,19 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
]
return self.status in cancellable_statuses
+ def retry(self):
+ """See `IOCIRecipeBuild`."""
+ assert self.can_be_retried, "Build %s cannot be retried" % self.id
+ self.build_farm_job.status = self.status = BuildStatus.NEEDSBUILD
+ self.build_farm_job.date_finished = self.date_finished = None
+ self.date_started = None
+ self.build_farm_job.builder = self.builder = None
+ self.log = None
+ self.upload_log = None
+ self.dependencies = None
+ self.failure_count = 0
+ self.queueBuild()
+
def rescore(self, score):
"""See `IOCIRecipeBuild`."""
assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
diff --git a/lib/lp/oci/templates/ocirecipebuild-index.pt b/lib/lp/oci/templates/ocirecipebuild-index.pt
index bea084b..dc896e2 100644
--- a/lib/lp/oci/templates/ocirecipebuild-index.pt
+++ b/lib/lp/oci/templates/ocirecipebuild-index.pt
@@ -83,6 +83,9 @@
on <a tal:content="context/builder/title"
tal:attributes="href context/builder/fmt:url"/>
</tal:built>
+ <tal:retry define="link context/menu:context/retry"
+ condition="link/enabled"
+ replace="structure link/fmt:link" />
<tal:cancel define="link context/menu:context/cancel"
condition="link/enabled"
replace="structure link/fmt:link" />
diff --git a/lib/lp/oci/templates/ocirecipebuild-retry.pt b/lib/lp/oci/templates/ocirecipebuild-retry.pt
new file mode 100644
index 0000000..ba08004
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipebuild-retry.pt
@@ -0,0 +1,28 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+
+ <div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <div metal:fill-slot="extra_info">
+ <p>
+ The status of <dfn tal:content="context/title" /> is
+ <span tal:replace="context/status/title" />.
+ </p>
+ <p>Retrying this build will destroy its history and logs.</p>
+ <p>
+ By default, this build will be retried only after other pending
+ builds; please contact a build daemon administrator if you need
+ special treatment.
+ </p>
+ </div>
+ </div>
+ </div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 1dbd170..4f558f3 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -5,9 +5,13 @@
from __future__ import absolute_import, print_function, unicode_literals
-from datetime import timedelta
+from datetime import (
+ datetime,
+ timedelta,
+ )
from fixtures import FakeLogger
+import pytz
import six
from testtools.matchers import (
ContainsDict,
@@ -47,6 +51,7 @@ from lp.services.webapp.publisher import canonical_url
from lp.services.webhooks.testing import LogsScheduledWebhooks
from lp.testing import (
admin_logged_in,
+ person_logged_in,
StormStatementRecorder,
TestCaseWithFactory,
)
@@ -144,6 +149,30 @@ class TestOCIRecipeBuild(OCIConfigHelperMixin, TestCaseWithFactory):
self.build.getLayerFileByDigest,
'missing')
+ def test_can_be_retried(self):
+ ok_cases = [
+ BuildStatus.FAILEDTOBUILD,
+ BuildStatus.MANUALDEPWAIT,
+ BuildStatus.CHROOTWAIT,
+ BuildStatus.FAILEDTOUPLOAD,
+ BuildStatus.CANCELLED,
+ BuildStatus.SUPERSEDED,
+ ]
+ for status in BuildStatus.items:
+ build = self.factory.makeOCIRecipeBuild(status=status)
+ if status in ok_cases:
+ self.assertTrue(build.can_be_retried)
+ else:
+ self.assertFalse(build.can_be_retried)
+
+ def test_can_be_retried_obsolete_series(self):
+ # Builds for obsolete series cannot be retried.
+ distroseries = self.factory.makeDistroSeries(
+ status=SeriesStatus.OBSOLETE)
+ das = self.factory.makeDistroArchSeries(distroseries=distroseries)
+ build = self.factory.makeOCIRecipeBuild(distro_arch_series=das)
+ self.assertFalse(build.can_be_retried)
+
def test_can_be_cancelled(self):
# For all states that can be cancelled, can_be_cancelled returns True.
ok_cases = [
@@ -156,6 +185,22 @@ class TestOCIRecipeBuild(OCIConfigHelperMixin, TestCaseWithFactory):
else:
self.assertFalse(self.build.can_be_cancelled)
+ def test_retry_resets_state(self):
+ # Retrying a build resets most of the state attributes, but does
+ # not modify the first dispatch time.
+ now = datetime.now(pytz.UTC)
+ build = self.factory.makeOCIRecipeBuild()
+ build.updateStatus(BuildStatus.BUILDING, date_started=now)
+ build.updateStatus(BuildStatus.FAILEDTOBUILD)
+ build.gotFailure()
+ with person_logged_in(build.recipe.owner):
+ build.retry()
+ self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
+ self.assertEqual(now, build.date_first_dispatched)
+ self.assertIsNone(build.log)
+ self.assertIsNone(build.upload_log)
+ self.assertEqual(0, build.failure_count)
+
def test_cancel_not_in_progress(self):
# The cancel() method for a pending build leaves it in the CANCELLED
# state.