← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:add-build-behaviour-for-craft-recipes into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-build-behaviour-for-craft-recipes into launchpad:master.

Commit message:
Add a build behaviour for craft recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/473994
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-build-behaviour-for-craft-recipes into launchpad:master.
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index 87569c8..0c4dc43 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -47,6 +47,13 @@
             interface="lp.crafts.interfaces.craftrecipe.ICraftRecipeBuildRequest" />
     </class>
 
+    <!-- CraftRecipeBuildBehaviour -->
+    <adapter
+        for="lp.craft.interfaces.craftrecipebuild.ICraftRecipeBuild"
+        provides="lp.buildmaster.interfaces.buildfarmjobbehaviour.IBuildFarmJobBehaviour"
+        factory="lp.craft.model.craftrecipebuildbehaviour.CraftRecipeBuildBehaviour"
+        permission="zope.Public" />
+
     <!-- crafts-related jobs -->
     <class class="lp.crafts.model.craftrecipejob.CraftRecipeJob">
         <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
diff --git a/lib/lp/crafts/model/craftrecipebuildbehaviour.py b/lib/lp/crafts/model/craftrecipebuildbehaviour.py
new file mode 100644
index 0000000..5ff7740
--- /dev/null
+++ b/lib/lp/crafts/model/craftrecipebuildbehaviour.py
@@ -0,0 +1,122 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""An `IBuildFarmJobBehaviour` for `CraftRecipeBuild`.
+
+Dispatches craft recipe build jobs to build-farm workers.
+"""
+
+__all__ = [
+    "CraftRecipeBuildBehaviour",
+]
+
+from typing import Any, Generator
+
+from twisted.internet import defer
+from zope.component import adapter
+from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.builderproxy import BuilderProxyMixin
+from lp.buildmaster.enums import BuildBaseImageType
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+    BuildArgs,
+    IBuildFarmJobBehaviour,
+)
+from lp.buildmaster.model.buildfarmjobbehaviour import (
+    BuildFarmJobBehaviourBase,
+)
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
+from lp.registry.interfaces.series import SeriesStatus
+from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
+
+
+@adapter(ICraftRecipeBuild)
+@implementer(IBuildFarmJobBehaviour)
+class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
+    """Dispatches `CraftRecipeBuild` jobs to workers."""
+
+    builder_type = "craft"
+    image_types = [BuildBaseImageType.LXD, BuildBaseImageType.CHROOT]
+
+    def getLogFileName(self):
+        das = self.build.distro_arch_series
+
+        # Examples:
+        #   buildlog_craft_ubuntu_wily_amd64_name_FULLYBUILT.txt
+        return "buildlog_craft_%s_%s_%s_%s_%s.txt" % (
+            das.distroseries.distribution.name,
+            das.distroseries.name,
+            das.architecturetag,
+            self.build.recipe.name,
+            self.build.status.name,
+        )
+
+    def verifyBuildRequest(self, logger):
+        """Assert some pre-build checks.
+
+        The build request is checked:
+         * Virtualized builds can't build on a non-virtual builder
+         * Ensure that we have a chroot
+        """
+        build = self.build
+        if build.virtualized and not self._builder.virtualized:
+            raise AssertionError(
+                "Attempt to build virtual item on a non-virtual builder."
+            )
+
+        chroot = build.distro_arch_series.getChroot()
+        if chroot is None:
+            raise CannotBuild(
+                "Missing chroot for %s" % build.distro_arch_series.displayname
+            )
+
+    @defer.inlineCallbacks
+    def extraBuildArgs(self, logger=None) -> Generator[Any, Any, BuildArgs]:
+        """
+        Return the extra arguments required by the worker for the given build.
+        """
+        build = self.build
+        args: BuildArgs = yield super().extraBuildArgs(logger=logger)
+        yield self.startProxySession(args)
+        args["name"] = build.recipe.store_name or build.recipe.name
+        channels = build.channels or {}
+        # We have to remove the security proxy that Zope applies to this
+        # dict, since otherwise we'll be unable to serialise it to XML-RPC.
+        args["channels"] = removeSecurityProxy(channels)
+        (
+            args["archives"],
+            args["trusted_keys"],
+        ) = yield get_sources_list_for_building(
+            self, build.distro_arch_series, None, logger=logger
+        )
+        if build.recipe.build_path is not None:
+            args["build_path"] = build.recipe.build_path
+        if build.recipe.git_ref is not None:
+            args["git_repository"] = build.recipe.git_repository.git_https_url
+            # "git clone -b" doesn't accept full ref names.  If this becomes
+            # a problem then we could change launchpad-buildd to do "git
+            # clone" followed by "git checkout" instead.
+            if build.recipe.git_path != "HEAD":
+                args["git_path"] = build.recipe.git_ref.name
+        else:
+            raise CannotBuild(
+                "Source repository for ~%s/%s/+craft/%s has been deleted."
+                % (
+                    build.recipe.owner.name,
+                    build.recipe.project.name,
+                    build.recipe.name,
+                )
+            )
+        args["private"] = build.is_private
+        return args
+
+    def verifySuccessfulBuild(self):
+        """See `IBuildFarmJobBehaviour`."""
+        # The implementation in BuildFarmJobBehaviourBase checks whether the
+        # target suite is modifiable in the target archive.  However, a
+        # `CraftRecipeBuild`'s archive is a source rather than a target, so
+        # that check does not make sense.  We do, however, refuse to build
+        # for obsolete series.
+        assert self.build.distro_series.status != SeriesStatus.OBSOLETE
diff --git a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
new file mode 100644
index 0000000..421fb88
--- /dev/null
+++ b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
@@ -0,0 +1,465 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test craft recipe build behaviour."""
+
+import os.path
+
+import transaction
+from pymacaroons import Macaroon
+from testtools import ExpectedException
+from testtools.matchers import (
+    Equals,
+    Is,
+    IsInstance,
+    MatchesDict,
+    MatchesListwise,
+)
+from testtools.twistedsupport import (
+    AsynchronousDeferredRunTestForBrokenTwisted,
+)
+from twisted.internet import defer
+from zope.component import getUtility
+from zope.proxy import isProxy
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.archivepublisher.interfaces.archivegpgsigningkey import (
+    IArchiveGPGSigningKey,
+)
+from lp.buildmaster.enums import BuildBaseImageType, BuildStatus
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+    IBuildFarmJobBehaviour,
+)
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.buildmaster.tests.mock_workers import MockBuilder, OkWorker
+from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
+    TestGetUploadMethodsMixin,
+    TestHandleStatusMixin,
+    TestVerifySuccessfulBuildMixin,
+)
+from lp.crafts.interfaces.craftrecipe import (
+    CRAFT_RECIPE_ALLOW_CREATE,
+    CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+)
+from lp.crafts.model.craftrecipebuildbehaviour import CraftRecipeBuildBehaviour
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.config import config
+from lp.services.features.testing import FeatureFixture
+from lp.services.log.logger import BufferLogger, DevNullLogger
+from lp.services.statsd.tests import StatsMixin
+from lp.services.webapp import canonical_url
+from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
+from lp.soyuz.enums import PackagePublishingStatus
+from lp.soyuz.tests.soyuz import Base64KeyMatches
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.gpgkeys import gpgkeysdir
+from lp.testing.keyserver import InProcessKeyServerFixture
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class TestCraftRecipeBuildBehaviourBase(TestCaseWithFactory):
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+        super().setUp()
+
+    def makeJob(self, distribution=None, with_builder=False, **kwargs):
+        """Create a sample `ICraftRecipeBuildBehaviour`."""
+        if distribution is None:
+            distribution = self.factory.makeDistribution(name="distro")
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable"
+        )
+        processor = getUtility(IProcessorSet).getByName("386")
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries,
+            architecturetag="i386",
+            processor=processor,
+        )
+
+        # Taken from test_archivedependencies.py
+        for component_name in ("main", "universe"):
+            self.factory.makeComponentSelection(distroseries, component_name)
+
+        build = self.factory.makeCraftRecipeBuild(
+            distro_arch_series=distroarchseries, name="test-craft", **kwargs
+        )
+        job = IBuildFarmJobBehaviour(build)
+        if with_builder:
+            builder = MockBuilder()
+            builder.processor = processor
+            job.setBuilder(builder, None)
+        return job
+
+
+class TestCraftRecipeBuildBehaviour(TestCraftRecipeBuildBehaviourBase):
+    layer = LaunchpadZopelessLayer
+
+    def test_provides_interface(self):
+        # CraftRecipeBuildBehaviour provides IBuildFarmJobBehaviour.
+        job = CraftRecipeBuildBehaviour(None)
+        self.assertProvides(job, IBuildFarmJobBehaviour)
+
+    def test_adapts_ICraftRecipeBuild(self):
+        # IBuildFarmJobBehaviour adapts an ICraftRecipeBuild.
+        build = self.factory.makeCraftRecipeBuild()
+        job = IBuildFarmJobBehaviour(build)
+        self.assertProvides(job, IBuildFarmJobBehaviour)
+
+    def test_verifyBuildRequest_valid(self):
+        # verifyBuildRequest doesn't raise any exceptions when called with a
+        # valid builder set.
+        job = self.makeJob()
+        lfa = self.factory.makeLibraryFileAlias()
+        transaction.commit()
+        job.build.distro_arch_series.addOrUpdateChroot(lfa)
+        builder = MockBuilder()
+        job.setBuilder(builder, OkWorker())
+        logger = BufferLogger()
+        job.verifyBuildRequest(logger)
+        self.assertEqual("", logger.getLogBuffer())
+
+    def test_verifyBuildRequest_virtual_mismatch(self):
+        # verifyBuildRequest raises on an attempt to build a virtualized
+        # build on a non-virtual builder.
+        job = self.makeJob()
+        lfa = self.factory.makeLibraryFileAlias()
+        transaction.commit()
+        job.build.distro_arch_series.addOrUpdateChroot(lfa)
+        builder = MockBuilder(virtualized=False)
+        job.setBuilder(builder, OkWorker())
+        logger = BufferLogger()
+        e = self.assertRaises(AssertionError, job.verifyBuildRequest, logger)
+        self.assertEqual(
+            "Attempt to build virtual item on a non-virtual builder.", str(e)
+        )
+
+    def test_verifyBuildRequest_no_chroot(self):
+        # verifyBuildRequest raises when the DAS has no chroot.
+        job = self.makeJob()
+        builder = MockBuilder()
+        job.setBuilder(builder, OkWorker())
+        logger = BufferLogger()
+        e = self.assertRaises(CannotBuild, job.verifyBuildRequest, logger)
+        self.assertIn("Missing chroot", str(e))
+
+
+class TestAsyncCraftRecipeBuildBehaviour(
+    StatsMixin, TestCraftRecipeBuildBehaviourBase
+):
+
+    run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
+        timeout=30
+    )
+
+    def setUp(self):
+        super().setUp()
+        self.setUpStats()
+
+    @defer.inlineCallbacks
+    def test_composeBuildRequest(self):
+        job = self.makeJob(with_builder=True)
+        lfa = self.factory.makeLibraryFileAlias(db_only=True)
+        job.build.distro_arch_series.addOrUpdateChroot(lfa)
+        build_request = yield job.composeBuildRequest(None)
+        self.assertThat(
+            build_request,
+            MatchesListwise(
+                [
+                    Equals("craft"),
+                    Equals(job.build.distro_arch_series),
+                    Equals(job.build.pocket),
+                    Equals({}),
+                    IsInstance(dict),
+                ]
+            ),
+        )
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_git(self):
+        # extraBuildArgs returns appropriate arguments if asked to build a
+        # job for a Git branch.
+        [ref] = self.factory.makeGitRefs()
+        job = self.makeJob(git_ref=ref, with_builder=True)
+        expected_archives, expected_trusted_keys = (
+            yield get_sources_list_for_building(
+                job, job.build.distro_arch_series, None
+            )
+        )
+        for archive_line in expected_archives:
+            self.assertIn("universe", archive_line)
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertThat(
+            args,
+            MatchesDict(
+                {
+                    "archive_private": Is(False),
+                    "archives": Equals(expected_archives),
+                    "arch_tag": Equals("i386"),
+                    "build_url": Equals(canonical_url(job.build)),
+                    "builder_constraints": Equals([]),
+                    "channels": Equals({}),
+                    "fast_cleanup": Is(True),
+                    "git_repository": Equals(ref.repository.git_https_url),
+                    "git_path": Equals(ref.name),
+                    "name": Equals("test-craft"),
+                    "private": Is(False),
+                    "series": Equals("unstable"),
+                    "trusted_keys": Equals(expected_trusted_keys),
+                }
+            ),
+        )
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_git_HEAD(self):
+        # extraBuildArgs returns appropriate arguments if asked to build a
+        # job for the default branch in a Launchpad-hosted Git repository.
+        [ref] = self.factory.makeGitRefs()
+        removeSecurityProxy(ref.repository)._default_branch = ref.path
+        job = self.makeJob(
+            git_ref=ref.repository.getRefByPath("HEAD"), with_builder=True
+        )
+        expected_archives, expected_trusted_keys = (
+            yield get_sources_list_for_building(
+                job, job.build.distro_arch_series, None
+            )
+        )
+        for archive_line in expected_archives:
+            self.assertIn("universe", archive_line)
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertThat(
+            args,
+            MatchesDict(
+                {
+                    "archive_private": Is(False),
+                    "archives": Equals(expected_archives),
+                    "arch_tag": Equals("i386"),
+                    "build_url": Equals(canonical_url(job.build)),
+                    "builder_constraints": Equals([]),
+                    "channels": Equals({}),
+                    "fast_cleanup": Is(True),
+                    "git_repository": Equals(ref.repository.git_https_url),
+                    "name": Equals("test-craft"),
+                    "private": Is(False),
+                    "series": Equals("unstable"),
+                    "trusted_keys": Equals(expected_trusted_keys),
+                }
+            ),
+        )
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_prefers_store_name(self):
+        # For the "name" argument, extraBuildArgs prefers
+        # CraftRecipe.store_name over CraftRecipe.name if the former is set.
+        job = self.makeJob(store_name="something-else", with_builder=True)
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertEqual("something-else", args["name"])
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_archive_trusted_keys(self):
+        # If the archive has a signing key, extraBuildArgs sends it.
+        yield self.useFixture(InProcessKeyServerFixture()).start()
+        distribution = self.factory.makeDistribution()
+        key_path = os.path.join(gpgkeysdir, "ppa-sample@xxxxxxxxxxxxxxxxx")
+        yield IArchiveGPGSigningKey(distribution.main_archive).setSigningKey(
+            key_path, async_keyserver=True
+        )
+        job = self.makeJob(distribution=distribution, with_builder=True)
+        self.factory.makeBinaryPackagePublishingHistory(
+            distroarchseries=job.build.distro_arch_series,
+            pocket=job.build.pocket,
+            archive=distribution.main_archive,
+            status=PackagePublishingStatus.PUBLISHED,
+        )
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertThat(
+            args["trusted_keys"],
+            MatchesListwise(
+                [
+                    Base64KeyMatches(
+                        "0D57E99656BEFB0897606EE9A022DD1F5001B46D"
+                    ),
+                ]
+            ),
+        )
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_channels(self):
+        # If the build needs particular channels, extraBuildArgs sends them.
+        job = self.makeJob(channels={"craftcraft": "edge"}, with_builder=True)
+        expected_archives, expected_trusted_keys = (
+            yield get_sources_list_for_building(
+                job, job.build.distro_arch_series, None
+            )
+        )
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertFalse(isProxy(args["channels"]))
+        self.assertEqual({"craftcraft": "edge"}, args["channels"])
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_archives_primary(self):
+        # The build uses the release, security, and updates pockets from the
+        # primary archive.
+        job = self.makeJob(with_builder=True)
+        expected_archives = [
+            "deb %s %s main universe"
+            % (job.archive.archive_url, job.build.distro_series.name),
+            "deb %s %s-security main universe"
+            % (job.archive.archive_url, job.build.distro_series.name),
+            "deb %s %s-updates main universe"
+            % (job.archive.archive_url, job.build.distro_series.name),
+        ]
+        with dbuser(config.builddmaster.dbuser):
+            extra_args = yield job.extraBuildArgs()
+        self.assertEqual(expected_archives, extra_args["archives"])
+
+    @defer.inlineCallbacks
+    def test_extraBuildArgs_private(self):
+        # If the recipe is private, extraBuildArgs sends the appropriate
+        # arguments.
+        self.useFixture(
+            FeatureFixture(
+                {
+                    CRAFT_RECIPE_ALLOW_CREATE: "on",
+                    CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        job = self.makeJob(
+            information_type=InformationType.PROPRIETARY, with_builder=True
+        )
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertTrue(args["private"])
+
+    @defer.inlineCallbacks
+    def test_composeBuildRequest_git_ref_deleted(self):
+        # If the source Git reference has been deleted, composeBuildRequest
+        # raises CannotBuild.
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        owner = self.factory.makePerson(name="craft-owner")
+        project = self.factory.makeProduct(name="craft-project")
+        job = self.makeJob(
+            registrant=owner,
+            owner=owner,
+            project=project,
+            git_ref=ref,
+            with_builder=True,
+        )
+        repository.removeRefs([ref.path])
+        self.assertIsNone(job.build.recipe.git_ref)
+        expected_exception_msg = (
+            r"Source repository for "
+            r"~craft-owner/craft-project/\+craft/test-craft has been deleted."
+        )
+        with ExpectedException(CannotBuild, expected_exception_msg):
+            yield job.composeBuildRequest(None)
+
+    @defer.inlineCallbacks
+    def test_dispatchBuildToWorker_prefers_lxd(self):
+        job = self.makeJob()
+        builder = MockBuilder()
+        builder.processor = job.build.processor
+        worker = OkWorker()
+        job.setBuilder(builder, worker)
+        chroot_lfa = self.factory.makeLibraryFileAlias(db_only=True)
+        job.build.distro_arch_series.addOrUpdateChroot(
+            chroot_lfa, image_type=BuildBaseImageType.CHROOT
+        )
+        lxd_lfa = self.factory.makeLibraryFileAlias(db_only=True)
+        job.build.distro_arch_series.addOrUpdateChroot(
+            lxd_lfa, image_type=BuildBaseImageType.LXD
+        )
+        yield job.dispatchBuildToWorker(DevNullLogger())
+        self.assertEqual(
+            ("ensurepresent", lxd_lfa.http_url, "", ""), worker.call_log[0]
+        )
+        self.assertEqual(1, self.stats_client.incr.call_count)
+        self.assertEqual(
+            self.stats_client.incr.call_args_list[0][0],
+            (
+                "build.count,builder_name={},env=test,"
+                "job_type=CRAFTRECIPEBUILD,region={}".format(
+                    builder.name, builder.region
+                ),
+            ),
+        )
+
+    @defer.inlineCallbacks
+    def test_dispatchBuildToWorker_falls_back_to_chroot(self):
+        job = self.makeJob()
+        builder = MockBuilder()
+        builder.processor = job.build.processor
+        worker = OkWorker()
+        job.setBuilder(builder, worker)
+        chroot_lfa = self.factory.makeLibraryFileAlias(db_only=True)
+        job.build.distro_arch_series.addOrUpdateChroot(
+            chroot_lfa, image_type=BuildBaseImageType.CHROOT
+        )
+        yield job.dispatchBuildToWorker(DevNullLogger())
+        self.assertEqual(
+            ("ensurepresent", chroot_lfa.http_url, "", ""), worker.call_log[0]
+        )
+
+
+class MakeCraftRecipeBuildMixin:
+    """Provide the common makeBuild method returning a queued build."""
+
+    def makeCraftRecipe(self):
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+        return self.factory.makeCraftRecipe(
+            store_upload=True,
+            store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": Macaroon().serialize()},
+        )
+
+    def makeBuild(self):
+        recipe = self.makeCraftRecipe()
+        build = self.factory.makeCraftRecipeBuild(
+            requester=recipe.registrant,
+            recipe=recipe,
+            status=BuildStatus.BUILDING,
+        )
+        build.queueBuild()
+        return build
+
+    def makeUnmodifiableBuild(self):
+        recipe = self.makeCraftRecipe()
+        build = self.factory.makeCraftRecipeBuild(
+            requester=recipe.registrant,
+            recipe=recipe,
+            status=BuildStatus.BUILDING,
+        )
+        build.distro_series.status = SeriesStatus.OBSOLETE
+        build.queueBuild()
+        return build
+
+
+class TestGetUploadMethodsForCraftRecipeBuild(
+    MakeCraftRecipeBuildMixin, TestGetUploadMethodsMixin, TestCaseWithFactory
+):
+    """IPackageBuild.getUpload* methods work with craft recipe builds."""
+
+
+class TestVerifySuccessfulBuildForCraftRecipeBuild(
+    MakeCraftRecipeBuildMixin,
+    TestVerifySuccessfulBuildMixin,
+    TestCaseWithFactory,
+):
+    """IBuildFarmJobBehaviour.verifySuccessfulBuild works."""
+
+
+class TestHandleStatusForCraftRecipeBuild(
+    MakeCraftRecipeBuildMixin, TestHandleStatusMixin, TestCaseWithFactory
+):
+    """IPackageBuild.handleStatus works with craft recipe builds."""

Follow ups