← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:implement-craft-recipe-requests-builds-job into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:implement-craft-recipe-requests-builds-job into launchpad:master with ~ruinedyourlife/launchpad:add-sourcecraft-yaml-parser as a prerequisite.

Commit message:
Add run logic to CraftRecipeRequestBuildsJob

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/474137
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:implement-craft-recipe-requests-builds-job into launchpad:master.
diff --git a/charm/launchpad-scripts/layer.yaml b/charm/launchpad-scripts/layer.yaml
index 71c739e..3e86dff 100644
--- a/charm/launchpad-scripts/layer.yaml
+++ b/charm/launchpad-scripts/layer.yaml
@@ -18,6 +18,7 @@ options:
           - charm-build-job
           - checkwatches
           - copy_packages
+          - craft-build-job
           - cve
           - distributionmirror
           - distroseriesdifferencejob
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index 905b609..34090aa 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -6,6 +6,8 @@
 __all__ = [
     "BadCraftRecipeSource",
     "BadCraftRecipeSearchContext",
+    "CannotFetchSourcecraftYaml",
+    "CannotParseSourcecraftYaml",
     "CRAFT_RECIPE_ALLOW_CREATE",
     "CRAFT_RECIPE_PRIVATE_FEATURE_FLAG",
     "CraftRecipeBuildAlreadyPending",
@@ -19,6 +21,7 @@ __all__ = [
     "ICraftRecipe",
     "ICraftRecipeBuildRequest",
     "ICraftRecipeSet",
+    "MissingSourcecraftYaml",
     "NoSourceForCraftRecipe",
     "NoSuchCraftRecipe",
 ]
@@ -130,6 +133,23 @@ class BadCraftRecipeSearchContext(Exception):
     """The context is not valid for a craft recipe search."""
 
 
+class MissingSourcecraftYaml(Exception):
+    """The repository for this craft recipe does not have a
+    sourcecraft.yaml.
+    """
+
+    def __init__(self, branch_name):
+        super().__init__("Cannot find sourcecraft.yaml in %s" % branch_name)
+
+
+class CannotFetchSourcecraftYaml(Exception):
+    """Launchpad cannot fetch this craft recipe's sourcecraft.yaml."""
+
+
+class CannotParseSourcecraftYaml(Exception):
+    """Launchpad cannot parse this craft recipe's sourcecraft.yaml."""
+
+
 @error_status(http.client.BAD_REQUEST)
 class CraftRecipeBuildAlreadyPending(Exception):
     """A build was requested when an identical build was already pending."""
@@ -311,6 +331,31 @@ class ICraftRecipeView(Interface):
         :return: An `ICraftRecipeBuildRequest`.
         """
 
+    def requestBuildsFromJob(
+        build_request,
+        channels=None,
+        architectures=None,
+        allow_failures=False,
+        logger=None,
+    ):
+        """Synchronous part of `CraftRecipe.requestBuilds`.
+
+        Request that the craft recipe be built for relevant architectures.
+
+        :param build_request: The `ICraftRecipeBuildRequest` job being
+            processed.
+        :param channels: A dictionary mapping snap names to channels to use
+            for these builds.
+        :param architectures: If not None, limit builds to architectures
+            with these architecture tags (in addition to any other
+            applicable constraints).
+        :param allow_failures: If True, log exceptions other than "already
+            pending" from individual build requests; if False, raise them to
+            the caller.
+        :param logger: An optional logger.
+        :return: A sequence of `ICraftRecipeBuild` instances.
+        """
+
     def getBuildRequest(job_id):
         """Get an asynchronous build request by ID.
 
@@ -555,3 +600,20 @@ class ICraftRecipeSet(Interface):
 
     def preloadDataForRecipes(recipes, user):
         """Load the data related to a list of craft recipes."""
+
+    def getSourcecraftYaml(context, logger=None):
+        """Fetch a recipe's sourcecraft.yaml from code hosting, if possible.
+
+        :param context: Either an `ICraftRecipe` or the source branch for a
+            craft recipe.
+        :param logger: An optional logger.
+
+        :return: The recipe's parsed sourcecraft.yaml.
+        :raises MissingSourcecraftYaml: if this recipe has no
+            sourcecraft.yaml.
+        :raises CannotFetchSourcecraftYaml: if it was not possible to fetch
+            sourcecraft.yaml from the code hosting backend for some other
+            reason.
+        :raises CannotParseSourcecraftYaml: if the fetched sourcecraft.yaml
+            cannot be parsed.
+        """
diff --git a/lib/lp/crafts/interfaces/craftrecipebuild.py b/lib/lp/crafts/interfaces/craftrecipebuild.py
index 256fa6d..e99e294 100644
--- a/lib/lp/crafts/interfaces/craftrecipebuild.py
+++ b/lib/lp/crafts/interfaces/craftrecipebuild.py
@@ -68,7 +68,7 @@ class ICraftRecipeBuildView(IPackageBuildView):
         description=_(
             "A dictionary mapping snap names to channels to use for this "
             "build.  Currently only 'core', 'core18', 'core20', "
-            "and 'craftcraft' keys are supported."
+            "and 'sourcecraft' keys are supported."
         ),
         key_type=TextLine(),
     )
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
index cdad7ff..addc6ee 100644
--- a/lib/lp/crafts/interfaces/craftrecipejob.py
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -58,7 +58,7 @@ class ICraftRecipeRequestBuildsJob(IRunnableJob):
         description=_(
             "A dictionary mapping snap names to channels to use for these "
             "builds.  Currently only 'core', 'core18', 'core20', and "
-            "'craftcraft' keys are supported."
+            "'sourcecraft' keys are supported."
         ),
         key_type=TextLine(),
         required=False,
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index 7a1dc0d..1deff6b 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -8,8 +8,9 @@ __all__ = [
 ]
 
 from datetime import timezone
-from operator import itemgetter
+from operator import attrgetter, itemgetter
 
+import yaml
 from lazr.lifecycle.event import ObjectCreatedEvent
 from storm.databases.postgres import JSON
 from storm.locals import (
@@ -33,12 +34,16 @@ from lp.app.enums import (
     InformationType,
 )
 from lp.buildmaster.enums import BuildStatus
+from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault
 from lp.code.model.gitcollection import GenericGitCollection
 from lp.code.model.gitrepository import GitRepository
 from lp.code.model.reciperegistry import recipe_registry
+from lp.crafts.adapters.buildarch import determine_instances_to_build
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
     CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+    CannotFetchSourcecraftYaml,
+    CannotParseSourcecraftYaml,
     CraftRecipeBuildAlreadyPending,
     CraftRecipeBuildDisallowedArchitecture,
     CraftRecipeBuildRequestStatus,
@@ -50,6 +55,7 @@ from lp.crafts.interfaces.craftrecipe import (
     ICraftRecipe,
     ICraftRecipeBuildRequest,
     ICraftRecipeSet,
+    MissingSourcecraftYaml,
     NoSourceForCraftRecipe,
 )
 from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuildSet
@@ -377,6 +383,88 @@ class CraftRecipe(StormBase):
         )
         return self.getBuildRequest(job.job_id)
 
+    def requestBuildsFromJob(
+        self,
+        build_request,
+        channels=None,
+        architectures=None,
+        allow_failures=False,
+        logger=None,
+    ):
+        """See `ICraftRecipe`."""
+        try:
+            sourcecraft_data = removeSecurityProxy(
+                getUtility(ICraftRecipeSet).getSourcecraftYaml(self)
+            )
+
+            # Sort by (Distribution.id, DistroSeries.id, Processor.id) for
+            # determinism.  This is chosen to be a similar order as in
+            # BinaryPackageBuildSet.createForSource, to minimize confusion.
+            supported_arches = [
+                das
+                for das in sorted(
+                    self.getAllowedArchitectures(),
+                    key=attrgetter(
+                        "distroseries.distribution.id",
+                        "distroseries.id",
+                        "processor.id",
+                    ),
+                )
+                if (
+                    architectures is None
+                    or das.architecturetag in architectures
+                )
+            ]
+            instances_to_build = determine_instances_to_build(
+                sourcecraft_data, supported_arches
+            )
+        except Exception as e:
+            if not allow_failures:
+                raise
+            elif logger is not None:
+                logger.exception(
+                    " - %s/%s/%s: %s",
+                    self.owner.name,
+                    self.project.name,
+                    self.name,
+                    e,
+                )
+
+        builds = []
+        for das in instances_to_build:
+            try:
+                build = self.requestBuild(
+                    build_request, das, channels=channels
+                )
+                if logger is not None:
+                    logger.debug(
+                        " - %s/%s/%s %s/%s/%s: Build requested.",
+                        self.owner.name,
+                        self.project.name,
+                        self.name,
+                        das.distroseries.distribution.name,
+                        das.distroseries.name,
+                        das.architecturetag,
+                    )
+                builds.append(build)
+            except CraftRecipeBuildAlreadyPending:
+                pass
+            except Exception as e:
+                if not allow_failures:
+                    raise
+                elif logger is not None:
+                    logger.exception(
+                        " - %s/%s/%s %s/%s/%s: %s",
+                        self.owner.name,
+                        self.project.name,
+                        self.name,
+                        das.distroseries.distribution.name,
+                        das.distroseries.name,
+                        das.architecturetag,
+                        e,
+                    )
+        return builds
+
     def getBuildRequest(self, job_id):
         """See `ICraftRecipe`."""
         return CraftRecipeBuildRequest(self, job_id)
@@ -527,6 +615,54 @@ class CraftRecipeSet:
             )
         )
 
+    def getSourcecraftYaml(self, context, logger=None):
+        """See `ICraftRecipeSet`."""
+        if ICraftRecipe.providedBy(context):
+            recipe = context
+            source = context.git_ref
+        else:
+            recipe = None
+            source = context
+        if source is None:
+            raise CannotFetchSourcecraftYaml("Craft source is not defined")
+        try:
+            path = "sourcecraft.yaml"
+            if recipe is not None and recipe.build_path is not None:
+                path = "/".join((recipe.build_path, path))
+            try:
+                blob = source.getBlob(path)
+            except GitRepositoryBlobNotFound:
+                if logger is not None:
+                    logger.exception(
+                        "Cannot find sourcecraft.yaml in %s",
+                        source.unique_name,
+                    )
+                raise MissingSourcecraftYaml(source.unique_name)
+        except GitRepositoryScanFault as e:
+            msg = "Failed to get sourcecraft.yaml from %s"
+            if logger is not None:
+                logger.exception(msg, source.unique_name)
+            raise CannotFetchSourcecraftYaml(
+                "%s: %s" % (msg % source.unique_name, e)
+            )
+
+        try:
+            sourcecraft_data = yaml.safe_load(blob)
+        except Exception as e:
+            # Don't bother logging parsing errors from user-supplied YAML.
+            raise CannotParseSourcecraftYaml(
+                "Cannot parse sourcecraft.yaml from %s: %s"
+                % (source.unique_name, e)
+            )
+
+        if not isinstance(sourcecraft_data, dict):
+            raise CannotParseSourcecraftYaml(
+                "The top level of sourcecraft.yaml from %s is not a mapping"
+                % source.unique_name
+            )
+
+        return sourcecraft_data
+
 
 @implementer(ICraftRecipeBuildRequest)
 class CraftRecipeBuildRequest:
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
index f249360..799e6e7 100644
--- a/lib/lp/crafts/model/craftrecipejob.py
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -19,6 +19,11 @@ from zope.component import getUtility
 from zope.interface import implementer, provider
 
 from lp.app.errors import NotFoundError
+from lp.crafts.interfaces.craftrecipe import (
+    CannotFetchSourcecraftYaml,
+    CannotParseSourcecraftYaml,
+    MissingSourcecraftYaml,
+)
 from lp.crafts.interfaces.craftrecipejob import (
     ICraftRecipeJob,
     ICraftRecipeRequestBuildsJob,
@@ -156,6 +161,12 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
 
     class_job_type = CraftRecipeJobType.REQUEST_BUILDS
 
+    user_error_types = (
+        CannotParseSourcecraftYaml,
+        MissingSourcecraftYaml,
+    )
+    retry_error_types = (CannotFetchSourcecraftYaml,)
+
     max_retries = 5
 
     config = config.ICraftRecipeRequestBuildsJobSource
@@ -304,9 +315,13 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
             )
             return
         try:
-            # XXX ruinedyourlife 2024-09-25: Implement this once we have a
-            # CraftRecipeBuild model.
-            raise NotImplementedError
+            self.builds = self.recipe.requestBuildsFromJob(
+                self.build_request,
+                channels=self.channels,
+                architectures=self.architectures,
+                logger=log,
+            )
+            self.error_message = None
         except Exception as e:
             self.error_message = str(e)
             # The normal job infrastructure will abort the transaction, but
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index 1c0bc7b..47a44f6 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -3,6 +3,9 @@
 
 """Test craft recipes."""
 
+from textwrap import dedent
+
+import transaction
 from storm.locals import Store
 from testtools.matchers import (
     Equals,
@@ -15,6 +18,7 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.processor import (
@@ -22,6 +26,7 @@ from lp.buildmaster.interfaces.processor import (
     ProcessorNotFound,
 )
 from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.code.tests.helpers import GitHostingFixture
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
     CraftRecipeBuildAlreadyPending,
@@ -195,9 +200,9 @@ class TestCraftRecipe(TestCaseWithFactory):
         das = self.makeBuildableDistroArchSeries()
         build_request = self.factory.makeCraftRecipeBuildRequest(recipe=recipe)
         build = recipe.requestBuild(
-            build_request, das, channels={"craftcraft": "edge"}
+            build_request, das, channels={"sourcecraft": "edge"}
         )
-        self.assertEqual({"craftcraft": "edge"}, build.channels)
+        self.assertEqual({"sourcecraft": "edge"}, build.channels)
 
     def test_requestBuild_rejects_repeats(self):
         # requestBuild refuses if there is already a pending build.
@@ -330,7 +335,7 @@ class TestCraftRecipe(TestCaseWithFactory):
         now = get_transaction_timestamp(IStore(recipe))
         with person_logged_in(recipe.owner.teamowner):
             request = recipe.requestBuilds(
-                recipe.owner.teamowner, channels={"craftcraft": "edge"}
+                recipe.owner.teamowner, channels={"sourcecraft": "edge"}
             )
         self.assertThat(
             request,
@@ -340,7 +345,7 @@ class TestCraftRecipe(TestCaseWithFactory):
                 recipe=Equals(recipe),
                 status=Equals(CraftRecipeBuildRequestStatus.PENDING),
                 error_message=Is(None),
-                channels=MatchesDict({"craftcraft": Equals("edge")}),
+                channels=MatchesDict({"sourcecraft": Equals("edge")}),
                 architectures=Is(None),
             ),
         )
@@ -352,7 +357,7 @@ class TestCraftRecipe(TestCaseWithFactory):
                 job=MatchesStructure.byEquality(status=JobStatus.WAITING),
                 recipe=Equals(recipe),
                 requester=Equals(recipe.owner.teamowner),
-                channels=Equals({"craftcraft": "edge"}),
+                channels=Equals({"sourcecraft": "edge"}),
                 architectures=Is(None),
             ),
         )
@@ -391,6 +396,108 @@ class TestCraftRecipe(TestCaseWithFactory):
             ),
         )
 
+    def makeRequestBuildsJob(
+        self, distro_series_version, arch_tags, git_ref=None
+    ):
+        recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+        distro_series = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            version=distro_series_version,
+        )
+        for arch_tag in arch_tags:
+            self.makeBuildableDistroArchSeries(
+                distroseries=distro_series, architecturetag=arch_tag
+            )
+        return getUtility(ICraftRecipeRequestBuildsJobSource).create(
+            recipe, recipe.owner.teamowner, {"sourcecraft": "edge"}
+        )
+
+    def assertRequestedBuildsMatch(
+        self, builds, job, distro_series_version, arch_tags, channels
+    ):
+        self.assertThat(
+            builds,
+            MatchesSetwise(
+                *(
+                    MatchesStructure(
+                        requester=Equals(job.requester),
+                        recipe=Equals(job.recipe),
+                        distro_arch_series=MatchesStructure(
+                            distroseries=MatchesStructure.byEquality(
+                                version=distro_series_version
+                            ),
+                            architecturetag=Equals(arch_tag),
+                        ),
+                        channels=Equals(channels),
+                    )
+                    for arch_tag in arch_tags
+                )
+            ),
+        )
+
+    def test_requestBuildsFromJob_restricts_explicit_list(self):
+        # requestBuildsFromJob limits builds targeted at an explicit list of
+        # architectures to those allowed for the recipe.
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+            base: ubuntu@20.04
+            platforms:
+                sparc:
+                avr:
+            """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob("20.04", ["sparc", "avr", "mips64el"])
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request, channels=removeSecurityProxy(job.channels)
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "20.04", ["sparc", "avr"], job.channels
+        )
+
+    def test_requestBuildsFromJob_architectures_parameter(self):
+        # If an explicit set of architectures was given as a parameter,
+        # requestBuildsFromJob intersects those with any other constraints
+        # when requesting builds.
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+            base: ubuntu@20.04
+            platforms:
+                sparc:
+                i386:
+                avr:
+                riscv64:
+            """
+                )
+            )
+        )
+        job = self.makeRequestBuildsJob(
+            "20.04", ["avr", "mips64el", "riscv64"]
+        )
+        self.assertEqual(
+            get_transaction_timestamp(IStore(job.recipe)), job.date_created
+        )
+        transaction.commit()
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request,
+                channels=removeSecurityProxy(job.channels),
+                architectures={"avr", "riscv64"},
+            )
+        self.assertRequestedBuildsMatch(
+            builds, job, "20.04", ["avr", "riscv64"], job.channels
+        )
+
 
 class TestCraftRecipeSet(TestCaseWithFactory):
 
diff --git a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
index 94f6496..b11d81c 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
@@ -413,11 +413,11 @@ class TestAsyncCraftRecipeBuildBehaviour(
     @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)
+        job = self.makeJob(channels={"sourcecraft": "edge"}, with_builder=True)
         with dbuser(config.builddmaster.dbuser):
             args = yield job.extraBuildArgs()
         self.assertFalse(isProxy(args["channels"]))
-        self.assertEqual({"craftcraft": "edge"}, args["channels"])
+        self.assertEqual({"sourcecraft": "edge"}, args["channels"])
 
     @defer.inlineCallbacks
     def test_extraBuildArgs_archives_primary(self):
diff --git a/lib/lp/crafts/tests/test_craftrecipejob.py b/lib/lp/crafts/tests/test_craftrecipejob.py
index 8066856..11f3b47 100644
--- a/lib/lp/crafts/tests/test_craftrecipejob.py
+++ b/lib/lp/crafts/tests/test_craftrecipejob.py
@@ -3,7 +3,28 @@
 
 """Tests for craft recipe jobs."""
 
-from lp.crafts.interfaces.craftrecipe import CRAFT_RECIPE_ALLOW_CREATE
+from textwrap import dedent
+
+import six
+from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
+    Equals,
+    GreaterThan,
+    Is,
+    LessThan,
+    MatchesAll,
+    MatchesSetwise,
+    MatchesStructure,
+)
+from zope.component import getUtility
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.code.tests.helpers import GitHostingFixture
+from lp.crafts.interfaces.craftrecipe import (
+    CRAFT_RECIPE_ALLOW_CREATE,
+    CannotParseSourcecraftYaml,
+)
 from lp.crafts.interfaces.craftrecipejob import (
     ICraftRecipeJob,
     ICraftRecipeRequestBuildsJob,
@@ -13,8 +34,15 @@ from lp.crafts.model.craftrecipejob import (
     CraftRecipeJobType,
     CraftRecipeRequestBuildsJob,
 )
+from lp.services.config import config
+from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
 from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
+from lp.services.mail.sendmail import format_address_for_person
 from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import ZopelessDatabaseLayer
 
 
@@ -45,7 +73,7 @@ class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
 
     def test_provides_interface(self):
         # `CraftRecipeRequestBuildsJob` objects provide
-        # `ICraftRecipeRequestBuildsJob`."""
+        # `ICraftRecipeRequestBuildsJob`.
         recipe = self.factory.makeCraftRecipe()
         job = CraftRecipeRequestBuildsJob.create(recipe, recipe.registrant)
         self.assertProvides(job, ICraftRecipeRequestBuildsJob)
@@ -59,3 +87,181 @@ class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
             % (recipe.owner.name, recipe.project.name, recipe.name),
             repr(job),
         )
+
+    def makeSeriesAndProcessors(self, distro_series_version, arch_tags):
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            version=distro_series_version,
+        )
+        processors = [
+            self.factory.makeProcessor(
+                name=arch_tag, supports_virtualized=True
+            )
+            for arch_tag in arch_tags
+        ]
+        for processor in processors:
+            das = self.factory.makeDistroArchSeries(
+                distroseries=distroseries,
+                architecturetag=processor.name,
+                processor=processor,
+            )
+            das.addOrUpdateChroot(
+                self.factory.makeLibraryFileAlias(
+                    filename="fake_chroot.tar.gz", db_only=True
+                )
+            )
+        return distroseries, processors
+
+    def test_run(self):
+        # The job requests builds and records the result.
+        distroseries, _ = self.makeSeriesAndProcessors(
+            "20.04", ["avr2001", "sparc64", "x32"]
+        )
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+        expected_date_created = get_transaction_timestamp(IStore(recipe))
+        job = CraftRecipeRequestBuildsJob.create(
+            recipe, recipe.registrant, channels={"core": "stable"}
+        )
+        sourcecraft_yaml = dedent(
+            """\
+            base: ubuntu@20.04
+            platforms:
+                avr2001:
+                x32:
+            """
+        )
+        self.useFixture(GitHostingFixture(blob=sourcecraft_yaml))
+        with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+            JobRunner([job]).runAll()
+        now = get_transaction_timestamp(IStore(recipe))
+        self.assertEmailQueueLength(0)
+        self.assertThat(
+            job,
+            MatchesStructure(
+                job=MatchesStructure.byEquality(status=JobStatus.COMPLETED),
+                date_created=Equals(expected_date_created),
+                date_finished=MatchesAll(
+                    GreaterThan(expected_date_created), LessThan(now)
+                ),
+                error_message=Is(None),
+                builds=AfterPreprocessing(
+                    set,
+                    MatchesSetwise(
+                        *[
+                            MatchesStructure(
+                                build_request=MatchesStructure.byEquality(
+                                    id=job.job.id
+                                ),
+                                requester=Equals(recipe.registrant),
+                                recipe=Equals(recipe),
+                                distro_arch_series=Equals(distroseries[arch]),
+                                channels=Equals({"core": "stable"}),
+                            )
+                            for arch in ("avr2001", "x32")
+                        ]
+                    ),
+                ),
+            ),
+        )
+
+    def test_run_with_architectures(self):
+        # If the user explicitly requested architectures, the job passes
+        # those through when requesting builds, intersecting them with other
+        # constraints.
+        distroseries, _ = self.makeSeriesAndProcessors(
+            "20.04", ["avr2001", "sparc64", "x32"]
+        )
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+        expected_date_created = get_transaction_timestamp(IStore(recipe))
+        job = CraftRecipeRequestBuildsJob.create(
+            recipe,
+            recipe.registrant,
+            channels={"core": "stable"},
+            architectures=["sparc64", "x32"],
+        )
+        sourcecraft_yaml = dedent(
+            """\
+            base: ubuntu@20.04
+            platforms:
+                x32:
+            """
+        )
+        self.useFixture(GitHostingFixture(blob=sourcecraft_yaml))
+        with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+            JobRunner([job]).runAll()
+        now = get_transaction_timestamp(IStore(recipe))
+        self.assertEmailQueueLength(0)
+        self.assertThat(
+            job,
+            MatchesStructure(
+                job=MatchesStructure.byEquality(status=JobStatus.COMPLETED),
+                date_created=Equals(expected_date_created),
+                date_finished=MatchesAll(
+                    GreaterThan(expected_date_created), LessThan(now)
+                ),
+                error_message=Is(None),
+                builds=AfterPreprocessing(
+                    set,
+                    MatchesSetwise(
+                        MatchesStructure(
+                            build_request=MatchesStructure.byEquality(
+                                id=job.job.id
+                            ),
+                            requester=Equals(recipe.registrant),
+                            recipe=Equals(recipe),
+                            distro_arch_series=Equals(distroseries["x32"]),
+                            channels=Equals({"core": "stable"}),
+                        )
+                    ),
+                ),
+            ),
+        )
+
+    def test_run_failed(self):
+        # A failed run sets the job status to FAILED and records the error
+        # message.
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+        expected_date_created = get_transaction_timestamp(IStore(recipe))
+        job = CraftRecipeRequestBuildsJob.create(
+            recipe, recipe.registrant, channels={"core": "stable"}
+        )
+        self.useFixture(GitHostingFixture()).getBlob.failure = (
+            CannotParseSourcecraftYaml("Nonsense on stilts")
+        )
+        with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+            JobRunner([job]).runAll()
+        now = get_transaction_timestamp(IStore(recipe))
+        [notification] = self.assertEmailQueueLength(1)
+        self.assertThat(
+            dict(notification),
+            ContainsDict(
+                {
+                    "From": Equals(config.canonical.noreply_from_address),
+                    "To": Equals(format_address_for_person(recipe.registrant)),
+                    "Subject": Equals(
+                        "Launchpad error while requesting builds of %s"
+                        % recipe.name
+                    ),
+                }
+            ),
+        )
+        self.assertEqual(
+            "Launchpad encountered an error during the following operation: "
+            "requesting builds of %s.  Nonsense on stilts" % recipe.name,
+            six.ensure_text(notification.get_payload(decode=True)),
+        )
+        self.assertThat(
+            job,
+            MatchesStructure(
+                job=MatchesStructure.byEquality(status=JobStatus.FAILED),
+                date_created=Equals(expected_date_created),
+                date_finished=MatchesAll(
+                    GreaterThan(expected_date_created), LessThan(now)
+                ),
+                error_message=Equals("Nonsense on stilts"),
+                builds=AfterPreprocessing(set, MatchesSetwise()),
+            ),
+        )

Follow ups