← Back to team overview

launchpad-reviewers team mailing list archive

[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.