← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:refactor-build-retry-cancel-rescore into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:refactor-build-retry-cancel-rescore into launchpad:master.

Commit message:
Push retry/cancel/rescore down to IBuildFarmJob

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/413831

Previously, each build type implemented retry/cancel/rescore logic itself, even though the implementations were almost identical.  Push this down to `IBuildFarmJob` and `BuildFarmJobMixin` so that creating a new build type involves a bit less copy-and-paste.

I had to split `IBuildFarmJob` into `IBuildFarmJob{View,Edit,Admin}`, since the retry/cancel/rescore operations require varying amounts of privilege.

Source package recipe builds gain a `rescore` API operation, and live filesystem and source package recipe builds both gain `can_be_retried` and `retry` methods (although `can_be_retried` is currently always False for those build types, and `retry` will always fail, but that might change in future).

Attempting to retry or rescore a build that cannot be retried or rescored respectively now consistently returns HTTP 400 rather than returning HTTP 500 in some cases.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:refactor-build-retry-cancel-rescore into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 09fe6e4..af12d9a 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -742,8 +742,6 @@ patch_entry_explicit_version(IArchiveSubscriber, 'beta')
 
 # IBinaryPackageBuild
 patch_entry_explicit_version(IBinaryPackageBuild, 'beta')
-patch_operations_explicit_version(
-    IBinaryPackageBuild, 'beta', "rescore", "retry")
 
 # IBinaryPackagePublishingHistory
 patch_entry_explicit_version(IBinaryPackagePublishingHistory, 'beta')
diff --git a/lib/lp/buildmaster/interfaces/buildfarmjob.py b/lib/lp/buildmaster/interfaces/buildfarmjob.py
index 86d62e5..9e8d461 100644
--- a/lib/lp/buildmaster/interfaces/buildfarmjob.py
+++ b/lib/lp/buildmaster/interfaces/buildfarmjob.py
@@ -4,17 +4,28 @@
 """Interface for Soyuz build farm jobs."""
 
 __all__ = [
+    'CannotBeRescored',
+    'CannotBeRetried',
     'IBuildFarmJob',
+    'IBuildFarmJobAdmin',
     'IBuildFarmJobDB',
+    'IBuildFarmJobEdit',
     'IBuildFarmJobSet',
     'IBuildFarmJobSource',
+    'IBuildFarmJobView',
     'InconsistentBuildFarmJobError',
     'ISpecificBuildFarmJobSource',
     ]
 
+import http.client
+
 from lazr.restful.declarations import (
+    error_status,
+    export_write_operation,
     exported,
     exported_as_webservice_entry,
+    operation_for_version,
+    operation_parameters,
     )
 from lazr.restful.fields import Reference
 from zope.interface import (
@@ -49,6 +60,22 @@ class InconsistentBuildFarmJobError(Exception):
     """
 
 
+@error_status(http.client.BAD_REQUEST)
+class CannotBeRetried(Exception):
+    """Raised when retrying a build that cannot be retried."""
+
+    def __init__(self, build_id):
+        super().__init__("Build %s cannot be retried." % build_id)
+
+
+@error_status(http.client.BAD_REQUEST)
+class CannotBeRescored(Exception):
+    """Raised when rescoring a build that cannot be rescored."""
+
+    def __init__(self, build_id):
+        super().__init__("Build %s cannot be rescored." % build_id)
+
+
 class IBuildFarmJobDB(Interface):
     """Operations on a `BuildFarmJob` DB row.
 
@@ -63,9 +90,8 @@ class IBuildFarmJobDB(Interface):
         description=_("The specific type of job."))
 
 
-@exported_as_webservice_entry(as_of='beta')
-class IBuildFarmJob(Interface):
-    """Operations that jobs for the build farm must implement."""
+class IBuildFarmJobView(Interface):
+    """`IBuildFarmJob` attributes that require launchpad.View."""
 
     id = Attribute('The build farm job ID.')
 
@@ -238,6 +264,70 @@ class IBuildFarmJob(Interface):
         "Newline-separated list of repositories to be used to retrieve any "
         "external build-dependencies when performing this build.")
 
+    can_be_rescored = exported(Bool(
+        title=_("Can be rescored"), 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,
+        description=_("Whether this build record can be cancelled.")))
+
+
+class IBuildFarmJobEdit(Interface):
+    """`IBuildFarmJob` methods that require launchpad.Edit."""
+
+    def resetBuild():
+        """Reset this build record to a clean state.
+
+        This method should only be called by `BuildFarmJobMixin.retry`, but
+        subclasses may override it to reset additional state.
+        """
+
+    @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.
+        """
+
+    @export_write_operation()
+    @operation_for_version("devel")
+    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 IBuildFarmJobAdmin(Interface):
+    """`IBuildFarmJob` methods that require launchpad.Admin."""
+
+    @operation_parameters(score=Int(title=_("Score"), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def rescore(score):
+        """Change the build's score."""
+
+
+@exported_as_webservice_entry(as_of='beta')
+class IBuildFarmJob(IBuildFarmJobView, IBuildFarmJobEdit, IBuildFarmJobAdmin):
+    """Operations that jobs for the build farm must implement."""
+
 
 class ISpecificBuildFarmJobSource(Interface):
     """A utility for retrieving objects of a specific IBuildFarmJob type.
diff --git a/lib/lp/buildmaster/interfaces/packagebuild.py b/lib/lp/buildmaster/interfaces/packagebuild.py
index 8d6b836..3af81f7 100644
--- a/lib/lp/buildmaster/interfaces/packagebuild.py
+++ b/lib/lp/buildmaster/interfaces/packagebuild.py
@@ -5,6 +5,7 @@
 
 __all__ = [
     'IPackageBuild',
+    'IPackageBuildView',
     ]
 
 
@@ -18,7 +19,10 @@ from zope.schema import (
     )
 
 from lp import _
-from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJob
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJob,
+    IBuildFarmJobView,
+    )
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.pocket import PackagePublishingPocket
@@ -26,8 +30,8 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
 from lp.soyuz.interfaces.archive import IArchive
 
 
-class IPackageBuild(IBuildFarmJob):
-    """Attributes and operations specific to package build jobs."""
+class IPackageBuildView(IBuildFarmJobView):
+    """`IPackageBuild` methods that require launchpad.View."""
 
     archive = exported(
         Reference(
@@ -95,3 +99,7 @@ class IPackageBuild(IBuildFarmJob):
 
         :param changes: Changes file from the upload.
         """
+
+
+class IPackageBuild(IPackageBuildView, IBuildFarmJob):
+    """Attributes and operations specific to package build jobs."""
diff --git a/lib/lp/buildmaster/interfaces/webservice.py b/lib/lp/buildmaster/interfaces/webservice.py
index b899a8f..e6ca23e 100644
--- a/lib/lp/buildmaster/interfaces/webservice.py
+++ b/lib/lp/buildmaster/interfaces/webservice.py
@@ -10,6 +10,8 @@ which tells `lazr.restful` that it should look for webservice exports here.
 """
 
 __all__ = [
+    'CannotBeRescored',
+    'CannotBeRetried',
     'IBuilder',
     'IBuilderSet',
     'IBuildFarmJob',
@@ -21,7 +23,11 @@ from lp.buildmaster.interfaces.builder import (
     IBuilder,
     IBuilderSet,
     )
-from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJob
+from lp.buildmaster.interfaces.buildfarmjob import (
+    CannotBeRescored,
+    CannotBeRetried,
+    IBuildFarmJob,
+    )
 from lp.buildmaster.interfaces.processor import (
     IProcessor,
     IProcessorSet,
diff --git a/lib/lp/buildmaster/model/buildfarmjob.py b/lib/lp/buildmaster/model/buildfarmjob.py
index 876546d..d6e7f9a 100644
--- a/lib/lp/buildmaster/model/buildfarmjob.py
+++ b/lib/lp/buildmaster/model/buildfarmjob.py
@@ -34,6 +34,8 @@ from lp.buildmaster.enums import (
     BuildStatus,
     )
 from lp.buildmaster.interfaces.buildfarmjob import (
+    CannotBeRescored,
+    CannotBeRetried,
     IBuildFarmJob,
     IBuildFarmJobDB,
     IBuildFarmJobSet,
@@ -262,6 +264,81 @@ class BuildFarmJobMixin:
         del get_property_cache(self).buildqueue_record
         return queue_entry
 
+    @property
+    def can_be_retried(self):
+        """See `IBuildFarmJob`.
+
+        Implementations should override this method to first check whether
+        their associated build behaviour would accept the build if it
+        succeeded.
+        """
+        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 `IBuildFarmJob`."""
+        return (
+            self.buildqueue_record is not None and
+            self.status is BuildStatus.NEEDSBUILD)
+
+    @property
+    def can_be_cancelled(self):
+        """See `IBuildFarmJob`."""
+        if not self.buildqueue_record:
+            return False
+
+        cancellable_statuses = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+            ]
+        return self.status in cancellable_statuses
+
+    def resetBuild(self):
+        """See `IBuildFarmJob`."""
+        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
+
+    def retry(self):
+        """See `IBuildFarmJob`."""
+        if not self.can_be_retried:
+            raise CannotBeRetried(self.id)
+
+        self.resetBuild()
+        self.queueBuild()
+
+    def rescore(self, score):
+        """See `IBuildFarmJob`."""
+        if not self.can_be_rescored:
+            raise CannotBeRescored(self.id)
+
+        self.buildqueue_record.manualScore(score)
+
+    def cancel(self):
+        """See `IBuildFarmJob`."""
+        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()
+
 
 class SpecificBuildFarmJobSourceMixin:
 
diff --git a/lib/lp/buildmaster/tests/test_buildfarmjob.py b/lib/lp/buildmaster/tests/test_buildfarmjob.py
index a10dfb0..911582f 100644
--- a/lib/lp/buildmaster/tests/test_buildfarmjob.py
+++ b/lib/lp/buildmaster/tests/test_buildfarmjob.py
@@ -21,6 +21,7 @@ from lp.buildmaster.enums import (
     BuildStatus,
     )
 from lp.buildmaster.interfaces.buildfarmjob import (
+    CannotBeRetried,
     IBuildFarmJob,
     IBuildFarmJobSet,
     IBuildFarmJobSource,
@@ -119,6 +120,7 @@ class TestBuildFarmJobMixin(TestCaseWithFactory):
 
     def test_providesInterface(self):
         # BuildFarmJobMixin derivatives provide IBuildFarmJob
+        login('admin@xxxxxxxxxxxxx')
         self.assertProvides(self.build_farm_job, IBuildFarmJob)
 
     def test_duration_none(self):
@@ -149,7 +151,7 @@ class TestBuildFarmJobMixin(TestCaseWithFactory):
     def test_edit_build_farm_job(self):
         # Users with edit access can update attributes.
         login('admin@xxxxxxxxxxxxx')
-        self.assertRaises(AssertionError, self.build_farm_job.retry)
+        self.assertRaises(CannotBeRetried, self.build_farm_job.retry)
 
     def test_updateStatus_sets_status(self):
         # updateStatus always sets status.
diff --git a/lib/lp/buildmaster/tests/test_packagebuild.py b/lib/lp/buildmaster/tests/test_packagebuild.py
index 90d5adb..8a1b51c 100644
--- a/lib/lp/buildmaster/tests/test_packagebuild.py
+++ b/lib/lp/buildmaster/tests/test_packagebuild.py
@@ -35,6 +35,7 @@ class TestPackageBuildMixin(TestCaseWithFactory):
 
     def test_providesInterface(self):
         # PackageBuild provides IPackageBuild
+        login('admin@xxxxxxxxxxxxx')
         self.assertProvides(self.package_build, IPackageBuild)
 
     def test_updateStatus_MANUALDEPWAIT_sets_dependencies(self):
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 4a33e19..2bbfe04 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -23,7 +23,6 @@ from lazr.restful.declarations import (
     exported,
     exported_as_webservice_entry,
     operation_for_version,
-    operation_parameters,
     )
 from lazr.restful.fields import (
     CollectionField,
@@ -43,8 +42,15 @@ from zope.schema import (
     )
 
 from lp import _
-from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
-from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
 from lp.charms.interfaces.charmrecipe import (
     ICharmRecipe,
     ICharmRecipeBuildRequest,
@@ -100,7 +106,7 @@ class CharmRecipeBuildStoreUploadStatus(EnumeratedType):
         """)
 
 
-class ICharmRecipeBuildView(IPackageBuild):
+class ICharmRecipeBuildView(IPackageBuildView):
     """`ICharmRecipeBuild` attributes that require launchpad.View."""
 
     build_request = Reference(
@@ -141,21 +147,6 @@ class ICharmRecipeBuildView(IPackageBuild):
         title=_("Score of the related build farm job (if any)."),
         required=False, readonly=True))
 
-    can_be_rescored = exported(Bool(
-        title=_("Can be rescored"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be rescored manually.")))
-
-    can_be_retried = exported(Bool(
-        title=_("Can be retried"),
-        required=False, readonly=True,
-        description=_("Whether this build record can be retried.")))
-
-    can_be_cancelled = exported(Bool(
-        title=_("Can be cancelled"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be cancelled.")))
-
     eta = Datetime(
         title=_("The datetime when the build job is estimated to complete."),
         readonly=True)
@@ -229,7 +220,7 @@ class ICharmRecipeBuildView(IPackageBuild):
         """
 
 
-class ICharmRecipeBuildEdit(Interface):
+class ICharmRecipeBuildEdit(IBuildFarmJobEdit):
     """`ICharmRecipeBuild` methods that require launchpad.Edit."""
 
     def addFile(lfa):
@@ -248,48 +239,18 @@ class ICharmRecipeBuildEdit(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.
-        """
-
-    @export_write_operation()
-    @operation_for_version("devel")
-    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 ICharmRecipeBuildAdmin(Interface):
+class ICharmRecipeBuildAdmin(IBuildFarmJobAdmin):
     """`ICharmRecipeBuild` methods that require launchpad.Admin."""
 
-    @operation_parameters(score=Int(title=_("Score"), required=True))
-    @export_write_operation()
-    @operation_for_version("devel")
-    def rescore(score):
-        """Change the build's score."""
-
 
 # XXX cjwatson 2021-09-15 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
 # "devel".
 @exported_as_webservice_entry(as_of="beta")
 class ICharmRecipeBuild(
-        ICharmRecipeBuildView, ICharmRecipeBuildEdit, ICharmRecipeBuildAdmin):
+        ICharmRecipeBuildView, ICharmRecipeBuildEdit, ICharmRecipeBuildAdmin,
+        IPackageBuild):
     """A build record for a charm recipe."""
 
 
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index cbcdd9e..2374924 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -220,70 +220,12 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
 
     @property
     def can_be_retried(self):
-        """See `ICharmRecipeBuild`."""
+        """See `IBuildFarmJob`."""
         # 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 `ICharmRecipeBuild`."""
-        return (
-            self.buildqueue_record is not None and
-            self.status is BuildStatus.NEEDSBUILD)
-
-    @property
-    def can_be_cancelled(self):
-        """See `ICharmRecipeBuild`."""
-        if not self.buildqueue_record:
-            return False
-
-        cancellable_statuses = [
-            BuildStatus.BUILDING,
-            BuildStatus.NEEDSBUILD,
-            ]
-        return self.status in cancellable_statuses
-
-    def retry(self):
-        """See `ICharmRecipeBuild`."""
-        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 `ICharmRecipeBuild`."""
-        assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
-        self.buildqueue_record.manualScore(score)
-
-    def cancel(self):
-        """See `ICharmRecipeBuild`."""
-        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()
+        return super().can_be_retried
 
     def calculateScore(self):
         """See `IBuildFarmJob`."""
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index fb8e0d5..b5399af 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1196,6 +1196,9 @@
     <require
         permission="launchpad.Edit"
         interface="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuildEdit"/>
+    <require
+        permission="launchpad.Admin"
+        interface="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuildAdmin"/>
   </class>
 
   <securedutility
diff --git a/lib/lp/code/interfaces/sourcepackagerecipebuild.py b/lib/lp/code/interfaces/sourcepackagerecipebuild.py
index 82375f7..12fe659 100644
--- a/lib/lp/code/interfaces/sourcepackagerecipebuild.py
+++ b/lib/lp/code/interfaces/sourcepackagerecipebuild.py
@@ -8,26 +8,26 @@ __all__ = [
     'ISourcePackageRecipeBuildSource',
     ]
 
-from lazr.restful.declarations import (
-    export_write_operation,
-    exported,
-    exported_as_webservice_entry,
-    operation_for_version,
-    )
+from lazr.restful.declarations import exported_as_webservice_entry
 from lazr.restful.fields import (
     CollectionField,
     Reference,
     )
-from zope.interface import Interface
 from zope.schema import (
-    Bool,
     Int,
     Object,
     )
 
 from lp import _
-from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
-from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
 from lp.code.interfaces.sourcepackagerecipe import (
     ISourcePackageRecipe,
     ISourcePackageRecipeData,
@@ -38,7 +38,8 @@ from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuild
 from lp.soyuz.interfaces.sourcepackagerelease import ISourcePackageRelease
 
 
-class ISourcePackageRecipeBuildView(IPackageBuild):
+class ISourcePackageRecipeBuildView(IPackageBuildView):
+    """`ISourcePackageRecipeBuild` attributes that require launchpad.View."""
 
     id = Int(title=_("Identifier for this build."))
 
@@ -57,16 +58,6 @@ class ISourcePackageRecipeBuildView(IPackageBuild):
     recipe = Object(
         schema=ISourcePackageRecipe, title=_("The recipe being built."))
 
-    can_be_rescored = exported(Bool(
-        title=_("Can be rescored"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be rescored manually.")))
-
-    can_be_cancelled = exported(Bool(
-        title=_("Can be cancelled"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be cancelled.")))
-
     manifest = Object(
         schema=ISourcePackageRecipeData, title=_(
             'A snapshot of the recipe for this build.'))
@@ -82,31 +73,22 @@ class ISourcePackageRecipeBuildView(IPackageBuild):
         """Return the file under +files with specified name."""
 
 
-class ISourcePackageRecipeBuildEdit(Interface):
-
-    @export_write_operation()
-    @operation_for_version("devel")
-    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 ISourcePackageRecipeBuildEdit(IBuildFarmJobEdit):
+    """`ISourcePackageRecipeBuild` attributes that require launchpad.Edit."""
 
     def destroySelf():
         """Delete the build itself."""
 
 
+class ISourcePackageRecipeBuildAdmin(IBuildFarmJobAdmin):
+    """`ISourcePackageRecipeBuild` attributes that require launchpad.Admin."""
+
+
 @exported_as_webservice_entry()
 class ISourcePackageRecipeBuild(ISourcePackageRecipeBuildView,
-                                ISourcePackageRecipeBuildEdit):
+                                ISourcePackageRecipeBuildEdit,
+                                ISourcePackageRecipeBuildAdmin,
+                                IPackageBuild):
     """A build of a source package."""
 
 
diff --git a/lib/lp/code/model/sourcepackagerecipebuild.py b/lib/lp/code/model/sourcepackagerecipebuild.py
index 7be41ee..273d606 100644
--- a/lib/lp/code/model/sourcepackagerecipebuild.py
+++ b/lib/lp/code/model/sourcepackagerecipebuild.py
@@ -263,31 +263,7 @@ class SourcePackageRecipeBuild(SpecificBuildFarmJobSourceMixin,
                     builds.append(build)
         return builds
 
-    @property
-    def can_be_rescored(self):
-        """See `IBuild`."""
-        return self.status is BuildStatus.NEEDSBUILD
-
-    @property
-    def can_be_cancelled(self):
-        """See `ISourcePackageRecipeBuild`."""
-        if not self.buildqueue_record:
-            return False
-
-        cancellable_statuses = [
-            BuildStatus.BUILDING,
-            BuildStatus.NEEDSBUILD,
-            ]
-        return self.status in cancellable_statuses
-
-    def cancel(self):
-        """See `ISourcePackageRecipeBuild`."""
-        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()
+    can_be_retried = False
 
     def destroySelf(self):
         if self.buildqueue_record is not None:
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 32aabe0..b366f87 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -25,7 +25,6 @@ from lazr.restful.declarations import (
     exported,
     exported_as_webservice_entry,
     operation_for_version,
-    operation_parameters,
     )
 from lazr.restful.fields import (
     CollectionField,
@@ -47,8 +46,15 @@ from zope.schema import (
 
 from lp import _
 from lp.app.interfaces.launchpad import IPrivacy
-from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
-from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
 from lp.oci.interfaces.ocirecipe import (
     IOCIRecipe,
     IOCIRecipeBuildRequest,
@@ -141,7 +147,7 @@ class OCIRecipeBuildSetRegistryUploadStatus(EnumeratedType):
     """)
 
 
-class IOCIRecipeBuildView(IPackageBuild, IPrivacy):
+class IOCIRecipeBuildView(IPackageBuildView, IPrivacy):
     """`IOCIRecipeBuild` attributes that require launchpad.View permission."""
 
     build_request = Reference(
@@ -207,21 +213,6 @@ class IOCIRecipeBuildView(IPackageBuild, IPrivacy):
         title=_("Score of the related build farm job (if any)."),
         required=False, readonly=True))
 
-    can_be_rescored = exported(Bool(
-        title=_("Can be rescored"),
-        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,
-        description=_("Whether this build record can be cancelled.")))
-
     manifest = Attribute(_("The manifest of the image."))
 
     digests = Attribute(_("File containing the image digests."))
@@ -266,7 +257,7 @@ class IOCIRecipeBuildView(IPackageBuild, IPrivacy):
         """
 
 
-class IOCIRecipeBuildEdit(Interface):
+class IOCIRecipeBuildEdit(IBuildFarmJobEdit):
     """`IOCIRecipeBuild` attributes that require launchpad.Edit permission."""
 
     def addFile(lfa, layer_file_digest):
@@ -286,46 +277,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.
-        """
-
-    @export_write_operation()
-    @operation_for_version("devel")
-    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):
+class IOCIRecipeBuildAdmin(IBuildFarmJobAdmin):
     """`IOCIRecipeBuild` attributes that require launchpad.Admin permission."""
 
-    @operation_parameters(score=Int(title=_("Score"), required=True))
-    @export_write_operation()
-    @operation_for_version("devel")
-    def rescore(score):
-        """Change the build's score."""
-
 
 @exported_as_webservice_entry(
     publish_web_link=True, as_of="devel", singular_name="oci_recipe_build")
 class IOCIRecipeBuild(IOCIRecipeBuildAdmin, IOCIRecipeBuildEdit,
-                      IOCIRecipeBuildView):
+                      IOCIRecipeBuildView, IPackageBuild):
     """A build record for an OCI recipe."""
 
 
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index fc4e3e2..13cc08a 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -214,43 +214,12 @@ class OCIRecipeBuild(PackageBuildMixin, StormBase):
 
     @property
     def can_be_retried(self):
-        """See `IOCIRecipeBuild`."""
+        """See `IBuildFarmJob`."""
         # 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 (
-            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
+        return super().can_be_retried
 
     @property
     def is_private(self):
@@ -271,33 +240,6 @@ class OCIRecipeBuild(PackageBuildMixin, StormBase):
 
     private = is_private
 
-    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
-        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/security.py b/lib/lp/security.py
index 9f6690e..03c580f 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -1666,18 +1666,6 @@ class EditCodeImportMachine(OnlyBazaarExpertsAndAdmins):
     usedfor = ICodeImportMachine
 
 
-class AdminSourcePackageRecipeBuilds(AuthorizationBase):
-    """Control who can edit SourcePackageRecipeBuilds.
-
-    Access is restricted to Buildd Admins.
-    """
-    permission = 'launchpad.Admin'
-    usedfor = ISourcePackageRecipeBuild
-
-    def checkAuthenticated(self, user):
-        return user.in_buildd_admin
-
-
 class AdminDistributionTranslations(AuthorizationBase):
     """Class for deciding who can administer distribution translations.
 
diff --git a/lib/lp/snappy/interfaces/snapbuild.py b/lib/lp/snappy/interfaces/snapbuild.py
index 6d41fbe..4012391 100644
--- a/lib/lp/snappy/interfaces/snapbuild.py
+++ b/lib/lp/snappy/interfaces/snapbuild.py
@@ -25,7 +25,6 @@ from lazr.restful.declarations import (
     exported,
     exported_as_webservice_entry,
     operation_for_version,
-    operation_parameters,
     )
 from lazr.restful.fields import (
     CollectionField,
@@ -48,8 +47,15 @@ from zope.schema import (
 
 from lp import _
 from lp.app.interfaces.launchpad import IPrivacy
-from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
-from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.database.constants import DEFAULT
@@ -128,7 +134,7 @@ class SnapBuildStoreUploadStatus(EnumeratedType):
         """)
 
 
-class ISnapBuildView(IPackageBuild, IPrivacy):
+class ISnapBuildView(IPackageBuildView, IPrivacy):
     """`ISnapBuild` attributes that require launchpad.View permission."""
 
     build_request = Reference(
@@ -189,21 +195,6 @@ class ISnapBuildView(IPackageBuild, IPrivacy):
         title=_("Score of the related build farm job (if any)."),
         required=False, readonly=True))
 
-    can_be_rescored = exported(Bool(
-        title=_("Can be rescored"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be rescored manually.")))
-
-    can_be_retried = exported(Bool(
-        title=_("Can be retried"),
-        required=False, readonly=True,
-        description=_("Whether this build record can be retried.")))
-
-    can_be_cancelled = exported(Bool(
-        title=_("Can be cancelled"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be cancelled.")))
-
     eta = Datetime(
         title=_("The datetime when the build job is estimated to complete."),
         readonly=True)
@@ -298,7 +289,7 @@ class ISnapBuildView(IPackageBuild, IPrivacy):
         :return: A collection of URLs for this build."""
 
 
-class ISnapBuildEdit(Interface):
+class ISnapBuildEdit(IBuildFarmJobEdit):
     """`ISnapBuild` attributes that require launchpad.Edit."""
 
     def addFile(lfa):
@@ -317,47 +308,17 @@ class ISnapBuildEdit(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.
-        """
-
-    @export_write_operation()
-    @operation_for_version("devel")
-    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 ISnapBuildAdmin(Interface):
+class ISnapBuildAdmin(IBuildFarmJobAdmin):
     """`ISnapBuild` attributes that require launchpad.Admin."""
 
-    @operation_parameters(score=Int(title=_("Score"), required=True))
-    @export_write_operation()
-    @operation_for_version("devel")
-    def rescore(score):
-        """Change the build's score."""
-
 
 # XXX cjwatson 2014-05-06 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
 # "devel".
 @exported_as_webservice_entry(as_of="beta")
-class ISnapBuild(ISnapBuildView, ISnapBuildEdit, ISnapBuildAdmin):
+class ISnapBuild(
+        ISnapBuildView, ISnapBuildEdit, ISnapBuildAdmin, IPackageBuild):
     """Build information for snap package builds."""
 
 
diff --git a/lib/lp/snappy/model/snapbuild.py b/lib/lp/snappy/model/snapbuild.py
index 31955e9..96f6198 100644
--- a/lib/lp/snappy/model/snapbuild.py
+++ b/lib/lp/snappy/model/snapbuild.py
@@ -284,70 +284,12 @@ class SnapBuild(PackageBuildMixin, Storm):
 
     @property
     def can_be_retried(self):
-        """See `ISnapBuild`."""
+        """See `IBuildFarmJob`."""
         # 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 `ISnapBuild`."""
-        return (
-            self.buildqueue_record is not None and
-            self.status is BuildStatus.NEEDSBUILD)
-
-    @property
-    def can_be_cancelled(self):
-        """See `ISnapBuild`."""
-        if not self.buildqueue_record:
-            return False
-
-        cancellable_statuses = [
-            BuildStatus.BUILDING,
-            BuildStatus.NEEDSBUILD,
-            ]
-        return self.status in cancellable_statuses
-
-    def retry(self):
-        """See `ISnapBuild`."""
-        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 `ISnapBuild`."""
-        assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
-        self.buildqueue_record.manualScore(score)
-
-    def cancel(self):
-        """See `ISnapBuild`."""
-        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()
+        return super().can_be_retried
 
     def calculateScore(self):
         return 2510 + self.archive.relative_build_score
diff --git a/lib/lp/soyuz/interfaces/binarypackagebuild.py b/lib/lp/soyuz/interfaces/binarypackagebuild.py
index 3771af8..edc245f 100644
--- a/lib/lp/soyuz/interfaces/binarypackagebuild.py
+++ b/lib/lp/soyuz/interfaces/binarypackagebuild.py
@@ -5,21 +5,17 @@
 
 __all__ = [
     'BuildSetStatus',
-    'CannotBeRescored',
     'IBinaryPackageBuild',
     'IBuildRescoreForm',
     'IBinaryPackageBuildSet',
     'UnparsableDependencies',
     ]
 
-import http.client
-
 from lazr.enum import (
     EnumeratedType,
     Item,
     )
 from lazr.restful.declarations import (
-    error_status,
     export_read_operation,
     export_write_operation,
     exported,
@@ -42,24 +38,25 @@ from zope.schema import (
 
 from lp import _
 from lp.buildmaster.enums import BuildStatus
-from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
-from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
 from lp.buildmaster.interfaces.processor import IProcessor
 from lp.soyuz.interfaces.publishing import ISourcePackagePublishingHistory
 from lp.soyuz.interfaces.sourcepackagerelease import ISourcePackageRelease
 
 
-@error_status(http.client.BAD_REQUEST)
-class CannotBeRescored(Exception):
-    """Raised when rescoring a build that cannot be rescored."""
-    _message_prefix = "Cannot rescore build"
-
-
 class UnparsableDependencies(Exception):
     """Raised when parsing invalid dependencies on a binary package."""
 
 
-class IBinaryPackageBuildView(IPackageBuild):
+class IBinaryPackageBuildView(IPackageBuildView):
     """A Build interface for items requiring launchpad.View."""
     id = Int(title=_('ID'), required=True, readonly=True)
 
@@ -116,25 +113,6 @@ class IBinaryPackageBuildView(IPackageBuild):
         "A list of distroarchseriesbinarypackages that resulted from this"
         "build, ordered by name.")
 
-    can_be_rescored = exported(
-        Bool(
-            title=_("Can Be Rescored"), required=False, readonly=True,
-            description=_(
-                "Whether or not this build record can be rescored "
-                "manually.")))
-
-    can_be_retried = exported(
-        Bool(
-            title=_("Can Be Retried"), required=False, readonly=True,
-            description=_(
-                "Whether or not this build record can be retried.")))
-
-    can_be_cancelled = exported(
-        Bool(
-            title=_("Can Be Cancelled"), required=False, readonly=True,
-            description=_(
-                "Whether or not this build record can be cancelled.")))
-
     upload_changesfile = Attribute(
         "The `LibraryFileAlias` object containing the changes file which "
         "was originally uploaded with the results of this build. It's "
@@ -243,10 +221,19 @@ class IBinaryPackageBuildView(IPackageBuild):
         """
 
 
-class IBinaryPackageBuildEdit(Interface):
+class IBinaryPackageBuildEdit(IBuildFarmJobEdit):
     """A Build interface for items requiring launchpad.Edit."""
 
+    def addBuildInfo(buildinfo):
+        """Add a buildinfo file to this build.
+
+        :param buildinfo: An `ILibraryFileAlias`.
+        """
+
+    # Redeclaring from IBuildFarmJobEdit.retry since this was available in
+    # the beta version.
     @export_write_operation()
+    @operation_for_version("beta")
     def retry():
         """Restore the build record to its initial state.
 
@@ -254,28 +241,6 @@ class IBinaryPackageBuildEdit(Interface):
         non-scored BuildQueue entry is created for it.
         """
 
-    @export_write_operation()
-    @operation_for_version("devel")
-    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.
-        """
-
-    def addBuildInfo(buildinfo):
-        """Add a buildinfo file to this build.
-
-        :param buildinfo: An `ILibraryFileAlias`.
-        """
-
 
 class IBinaryPackageBuildRestricted(Interface):
     """Restricted `IBinaryPackageBuild` attributes.
@@ -297,11 +262,14 @@ class IBinaryPackageBuildRestricted(Interface):
         exported_as="external_dependencies")
 
 
-class IBinaryPackageBuildAdmin(Interface):
+class IBinaryPackageBuildAdmin(IBuildFarmJobAdmin):
     """A Build interface for items requiring launchpad.Admin."""
 
+    # Redeclaring from IBuildFarmJobEdit.rescore since this was available in
+    # the beta version.
     @operation_parameters(score=Int(title=_("Score"), required=True))
     @export_write_operation()
+    @operation_for_version("beta")
     def rescore(score):
         """Change the build's score."""
 
@@ -309,7 +277,8 @@ class IBinaryPackageBuildAdmin(Interface):
 @exported_as_webservice_entry(singular_name='build', plural_name='builds')
 class IBinaryPackageBuild(
     IBinaryPackageBuildView, IBinaryPackageBuildEdit,
-    IBinaryPackageBuildRestricted, IBinaryPackageBuildAdmin):
+    IBinaryPackageBuildRestricted, IBinaryPackageBuildAdmin,
+    IPackageBuild):
     """A Build interface"""
 
 
diff --git a/lib/lp/soyuz/interfaces/livefsbuild.py b/lib/lp/soyuz/interfaces/livefsbuild.py
index cbba840..5ed7098 100644
--- a/lib/lp/soyuz/interfaces/livefsbuild.py
+++ b/lib/lp/soyuz/interfaces/livefsbuild.py
@@ -11,11 +11,9 @@ __all__ = [
 
 from lazr.restful.declarations import (
     export_read_operation,
-    export_write_operation,
     exported,
     exported_as_webservice_entry,
     operation_for_version,
-    operation_parameters,
     )
 from lazr.restful.fields import Reference
 from zope.interface import Interface
@@ -29,8 +27,15 @@ from zope.schema import (
 
 from lp import _
 from lp.app.interfaces.launchpad import IPrivacy
-from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
-from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.database.constants import DEFAULT
@@ -53,7 +58,7 @@ class ILiveFSFile(Interface):
         required=True, readonly=True)
 
 
-class ILiveFSBuildView(IPackageBuild, IPrivacy):
+class ILiveFSBuildView(IPackageBuildView, IPrivacy):
     """`ILiveFSBuild` attributes that require launchpad.View permission."""
 
     requester = exported(Reference(
@@ -103,16 +108,6 @@ class ILiveFSBuildView(IPackageBuild, IPrivacy):
         title=_("Score of the related build farm job (if any)."),
         required=False, readonly=True))
 
-    can_be_rescored = exported(Bool(
-        title=_("Can be rescored"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be rescored manually.")))
-
-    can_be_cancelled = exported(Bool(
-        title=_("Can be cancelled"),
-        required=True, readonly=True,
-        description=_("Whether this build record can be cancelled.")))
-
     def getFiles():
         """Retrieve the build's `ILiveFSFile` records.
 
@@ -144,7 +139,7 @@ class ILiveFSBuildView(IPackageBuild, IPrivacy):
         :return: A collection of URLs for this build."""
 
 
-class ILiveFSBuildEdit(Interface):
+class ILiveFSBuildEdit(IBuildFarmJobEdit):
     """`ILiveFSBuild` attributes that require launchpad.Edit."""
 
     def addFile(lfa):
@@ -154,38 +149,17 @@ class ILiveFSBuildEdit(Interface):
         :return: An `ILiveFSFile`.
         """
 
-    @export_write_operation()
-    @operation_for_version("devel")
-    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 ILiveFSBuildAdmin(Interface):
+class ILiveFSBuildAdmin(IBuildFarmJobAdmin):
     """`ILiveFSBuild` attributes that require launchpad.Admin."""
 
-    @operation_parameters(score=Int(title=_("Score"), required=True))
-    @export_write_operation()
-    @operation_for_version("devel")
-    def rescore(score):
-        """Change the build's score."""
-
 
 # XXX cjwatson 2014-05-06 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
 # "devel".
 @exported_as_webservice_entry(singular_name="livefs_build", as_of="beta")
-class ILiveFSBuild(ILiveFSBuildView, ILiveFSBuildEdit, ILiveFSBuildAdmin):
+class ILiveFSBuild(
+        ILiveFSBuildView, ILiveFSBuildEdit, ILiveFSBuildAdmin, IPackageBuild):
     """Build information for live filesystem builds."""
 
 
diff --git a/lib/lp/soyuz/interfaces/webservice.py b/lib/lp/soyuz/interfaces/webservice.py
index 90ef0e1..b8ecec2 100644
--- a/lib/lp/soyuz/interfaces/webservice.py
+++ b/lib/lp/soyuz/interfaces/webservice.py
@@ -13,7 +13,6 @@ __all__ = [
     'AlreadySubscribed',
     'ArchiveDisabled',
     'ArchiveNotPrivate',
-    'CannotBeRescored',
     'CannotCopy',
     'CannotSwitchPrivacy',
     'CannotUploadToArchive',
@@ -81,10 +80,7 @@ from lp.soyuz.interfaces.archive import (
 from lp.soyuz.interfaces.archivedependency import IArchiveDependency
 from lp.soyuz.interfaces.archivepermission import IArchivePermission
 from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriber
-from lp.soyuz.interfaces.binarypackagebuild import (
-    CannotBeRescored,
-    IBinaryPackageBuild,
-    )
+from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuild
 from lp.soyuz.interfaces.binarypackagerelease import (
     IBinaryPackageReleaseDownloadCount,
     )
diff --git a/lib/lp/soyuz/model/binarypackagebuild.py b/lib/lp/soyuz/model/binarypackagebuild.py
index 511c975..f51f565 100644
--- a/lib/lp/soyuz/model/binarypackagebuild.py
+++ b/lib/lp/soyuz/model/binarypackagebuild.py
@@ -101,7 +101,6 @@ from lp.soyuz.interfaces.archive import (
     )
 from lp.soyuz.interfaces.binarypackagebuild import (
     BuildSetStatus,
-    CannotBeRescored,
     IBinaryPackageBuild,
     IBinaryPackageBuildSet,
     UnparsableDependencies,
@@ -475,64 +474,19 @@ class BinaryPackageBuild(PackageBuildMixin, SQLBase):
 
     @property
     def can_be_retried(self):
-        """See `IBuild`."""
+        """See `IBuildFarmJob`."""
         # First check that the slave scanner would pick up the build record
         # if we reset it.
         if not self.archive.canModifySuite(self.distro_series, self.pocket):
             # The slave scanner would not pick this up, so it cannot be
             # re-tried.
             return False
+        return super().can_be_retried
 
-        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 `IBuild`."""
-        return self.status is BuildStatus.NEEDSBUILD
-
-    @property
-    def can_be_cancelled(self):
-        """See `IBuild`."""
-        if not self.buildqueue_record:
-            return False
-
-        cancellable_statuses = [
-            BuildStatus.BUILDING,
-            BuildStatus.NEEDSBUILD,
-            ]
-        return self.status in cancellable_statuses
-
-    def retry(self):
-        """See `IBuild`."""
-        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
+    def resetBuild(self):
+        """See `IBuildFarmJob`."""
+        super().resetBuild()
         self.virtualized = is_build_virtualized(self.archive, self.processor)
-        self.queueBuild()
-
-    def rescore(self, score):
-        """See `IBuild`."""
-        if not self.can_be_rescored:
-            raise CannotBeRescored("Build cannot be rescored.")
-
-        self.buildqueue_record.manualScore(score)
 
     @property
     def api_score(self):
@@ -543,15 +497,6 @@ class BinaryPackageBuild(PackageBuildMixin, SQLBase):
         else:
             return self.buildqueue_record.lastscore
 
-    def cancel(self):
-        """See `IBinaryPackageBuild`."""
-        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 _parseDependencyToken(self, token):
         """Parse the given token.
 
diff --git a/lib/lp/soyuz/model/livefsbuild.py b/lib/lp/soyuz/model/livefsbuild.py
index f8312c2..7ec3e87 100644
--- a/lib/lp/soyuz/model/livefsbuild.py
+++ b/lib/lp/soyuz/model/livefsbuild.py
@@ -230,38 +230,7 @@ class LiveFSBuild(PackageBuildMixin, Storm):
         else:
             return self.buildqueue_record.lastscore
 
-    @property
-    def can_be_rescored(self):
-        """See `ILiveFSBuild`."""
-        return (
-            self.buildqueue_record is not None and
-            self.status is BuildStatus.NEEDSBUILD)
-
-    @property
-    def can_be_cancelled(self):
-        """See `ILiveFSBuild`."""
-        if not self.buildqueue_record:
-            return False
-
-        cancellable_statuses = [
-            BuildStatus.BUILDING,
-            BuildStatus.NEEDSBUILD,
-            ]
-        return self.status in cancellable_statuses
-
-    def rescore(self, score):
-        """See `ILiveFSBuild`."""
-        assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
-        self.buildqueue_record.manualScore(score)
-
-    def cancel(self):
-        """See `ILiveFSBuild`."""
-        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()
+    can_be_retried = False
 
     def calculateScore(self):
         return (
diff --git a/lib/lp/soyuz/stories/webservice/xx-builds.txt b/lib/lp/soyuz/stories/webservice/xx-builds.txt
index 1e7a44a..5148042 100644
--- a/lib/lp/soyuz/stories/webservice/xx-builds.txt
+++ b/lib/lp/soyuz/stories/webservice/xx-builds.txt
@@ -205,10 +205,9 @@ As can cprov who owns the PPA for the build:
     ...     cprov, permission=OAuthPermission.WRITE_PUBLIC)
     >>> print(cprov_webservice.named_post(
     ...     a_build['self_link'], 'retry'))
-    HTTP/1.1 500 Internal Server Error
+    HTTP/1.1 400 Bad Request
     ...
-    AssertionError: Build ... cannot be retried
-    <BLANKLINE>
+    Build ... cannot be retried.
 
 but in this case, although he has permission to retry the build, it
 failed because it was already retried by an admin.  This is reflected in the
@@ -265,4 +264,4 @@ alter the buildstate to one that cannot be retried:
     ...     a_build['self_link'], 'rescore', score=1000))
     HTTP/1.1 400 Bad Request
     ...
-    Build cannot be rescored.
+    Build ... cannot be rescored.
diff --git a/lib/lp/soyuz/tests/test_build.py b/lib/lp/soyuz/tests/test_build.py
index 6e83cde..8181a2d 100644
--- a/lib/lp/soyuz/tests/test_build.py
+++ b/lib/lp/soyuz/tests/test_build.py
@@ -11,6 +11,7 @@ import transaction
 from zope.component import getUtility
 
 from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.buildfarmjob import CannotBeRescored
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
@@ -20,10 +21,7 @@ from lp.soyuz.enums import (
     PackagePublishingPriority,
     PackageUploadStatus,
     )
-from lp.soyuz.interfaces.binarypackagebuild import (
-    CannotBeRescored,
-    IBinaryPackageBuildSet,
-    )
+from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.publishing import PackagePublishingStatus
 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher