← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~jugmac00/launchpad:implement-RockRecipeRequestsBuildsJob into launchpad:master

 

Jürgen Gmach has proposed merging ~jugmac00/launchpad:implement-RockRecipeRequestsBuildsJob into launchpad:master with ~jugmac00/launchpad:add-a-rockraft.yaml-parser-like-charms as a prerequisite.

Commit message:
Implement RockRecipeRequestBuildsJob

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/473246
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:implement-RockRecipeRequestsBuildsJob into launchpad:master.
diff --git a/lib/lp/rocks/adapters/buildarch.py b/lib/lp/rocks/adapters/buildarch.py
index 7f0cd5a..d2a1569 100644
--- a/lib/lp/rocks/adapters/buildarch.py
+++ b/lib/lp/rocks/adapters/buildarch.py
@@ -122,42 +122,17 @@ class RockBaseConfiguration:
         return cls(build_on, run_on=run_on)
 
 
-def determine_instances_to_build(
-    rockcraft_data, supported_arches, default_distro_series
-):
+def determine_instances_to_build(rockcraft_data, supported_arches):
     """Return a list of instances to build based on rockcraft.yaml.
 
     :param rockcraft_data: A parsed rockcraft.yaml.
     :param supported_arches: An ordered list of all `DistroArchSeries` that
         we can create builds for.  Note that these may span multiple
         `DistroSeries`.
-    :param default_distro_series: The default `DistroSeries` to use if
-        rockcraft.yaml does not explicitly declare any bases.
     :return: A list of `DistroArchSeries`.
     """
     bases_list = rockcraft_data.get("bases")
-
-    if bases_list:
-        configs = [
-            RockBaseConfiguration.from_dict(item) for item in bases_list
-        ]
-    else:
-        # If no bases are specified, build one for each supported
-        # architecture for the default series.
-        configs = [
-            RockBaseConfiguration(
-                [
-                    RockBase(
-                        default_distro_series.distribution.name,
-                        default_distro_series.version,
-                        das.architecturetag,
-                    ),
-                ]
-            )
-            for das in supported_arches
-            if das.distroseries == default_distro_series
-        ]
-
+    configs = [RockBaseConfiguration.from_dict(item) for item in bases_list]
     # Ensure that multiple `run-on` items don't overlap; this is ambiguous
     # and forbidden by rockcraft.
     run_ons = Counter()
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
index 18b5099..b803488 100644
--- a/lib/lp/rocks/interfaces/rockrecipe.py
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -6,10 +6,14 @@
 __all__ = [
     "BadRockRecipeSource",
     "BadRockRecipeSearchContext",
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     "ROCK_RECIPE_ALLOW_CREATE",
     "ROCK_RECIPE_PRIVATE_FEATURE_FLAG",
-<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 =======
+    "CannotFetchRockcraftYaml",
+    "CannotParseRockcraftYaml",
+    "ROCK_RECIPE_ALLOW_CREATE",
+    "ROCK_RECIPE_PRIVATE_FEATURE_FLAG",
     "RockRecipeBuildAlreadyPending",
     "RockRecipeBuildDisallowedArchitecture",
     "RockRecipeBuildRequestStatus",
@@ -21,10 +25,12 @@ __all__ = [
     "DuplicateRockRecipeName",
     "IRockRecipe",
 <<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+    "IRockRecipeSet",
 =======
     "IRockRecipeBuildRequest",
->>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     "IRockRecipeSet",
+    "MissingRockcraftYaml",
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     "NoSourceForRockRecipe",
     "NoSuchRockRecipe",
 ]
@@ -147,6 +153,21 @@ class BadRockRecipeSearchContext(Exception):
 
 <<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 =======
+class MissingRockcraftYaml(Exception):
+    """The repository for this rock recipe does not have a rockcraft.yaml."""
+
+    def __init__(self, branch_name):
+        super().__init__("Cannot find rockcraft.yaml in %s" % branch_name)
+
+
+class CannotFetchRockcraftYaml(Exception):
+    """Launchpad cannot fetch this rock recipe's rockcraft.yaml."""
+
+
+class CannotParseRockcraftYaml(Exception):
+    """Launchpad cannot parse this rock recipe's rockcraft.yaml."""
+
+
 @error_status(http.client.BAD_REQUEST)
 class RockRecipeBuildAlreadyPending(Exception):
     """A build was requested when an identical build was already pending."""
@@ -331,6 +352,31 @@ class IRockRecipeView(Interface):
         :return: An `IRockRecipeBuildRequest`.
         """
 
+    def requestBuildsFromJob(
+        build_request,
+        channels=None,
+        architectures=None,
+        allow_failures=False,
+        logger=None,
+    ):
+        """Synchronous part of `RockRecipe.requestBuilds`.
+
+        Request that the rock recipe be built for relevant architectures.
+
+        :param build_request: The `IRockRecipeBuildRequest` 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 `IRockRecipeBuild` instances.
+        """
+
     def getBuildRequest(job_id):
         """Get an asynchronous build request by ID.
 
@@ -564,6 +610,23 @@ class IRockRecipeSet(Interface):
     def preloadDataForRecipes(recipes, user):
         """Load the data related to a list of rock recipes."""
 
+    def getRockcraftYaml(context, logger=None):
+        """Fetch a recipe's rockcraft.yaml from code hosting, if possible.
+
+        :param context: Either an `IRockRecipe` or the source branch for a
+            rock recipe.
+        :param logger: An optional logger.
+
+        :return: The recipe's parsed rockcraft.yaml.
+        :raises MissingRockcraftYaml: if this recipe has no
+            rockcraft.yaml.
+        :raises CannotFetchRockcraftYaml: if it was not possible to fetch
+            rockcraft.yaml from the code hosting backend for some other
+            reason.
+        :raises CannotParseRockcraftYaml: if the fetched rockcraft.yaml
+            cannot be parsed.
+        """
+
 >>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     def findByGitRepository(repository, paths=None):
         """Return all rock recipes for the given Git repository.
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index 4ae64c7..a9de221 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -6,16 +6,18 @@
 __all__ = [
     "RockRecipe",
 ]
+<<<<<<< lib/lp/rocks/model/rockrecipe.py
 
 from datetime import timezone
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
 
 from storm.databases.postgres import JSON
 from storm.locals import Bool, DateTime, Int, Reference, Unicode
 from zope.component import getUtility
 =======
-from operator import itemgetter
+from datetime import timezone
+from operator import attrgetter, itemgetter
 
+import yaml
 from lazr.lifecycle.event import ObjectCreatedEvent
 from storm.databases.postgres import JSON
 from storm.locals import (
@@ -43,8 +45,16 @@ from lp.app.enums import (
 from lp.code.model.gitrepository import GitRepository
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.person import validate_public_person
+from lp.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
+    ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
+    DuplicateRockRecipeName,
+    IRockRecipe,
+    IRockRecipeSet,
+    NoSourceForRockRecipe,
 =======
 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.registry.errors import PrivatePersonLinkageError
@@ -52,18 +62,17 @@ from lp.registry.interfaces.person import IPersonSet, validate_public_person
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.series import ACTIVE_STATUSES
->>>>>>> lib/lp/rocks/model/rockrecipe.py
+from lp.rocks.adapters.buildarch import determine_instances_to_build
 from lp.rocks.interfaces.rockrecipe import (
     ROCK_RECIPE_ALLOW_CREATE,
     ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
+    CannotFetchRockcraftYaml,
+    CannotParseRockcraftYaml,
     DuplicateRockRecipeName,
     IRockRecipe,
-<<<<<<< lib/lp/rocks/model/rockrecipe.py
-    IRockRecipeSet,
-    NoSourceForRockRecipe,
-=======
     IRockRecipeBuildRequest,
     IRockRecipeSet,
+    MissingRockcraftYaml,
     NoSourceForRockRecipe,
     RockRecipeBuildAlreadyPending,
     RockRecipeBuildDisallowedArchitecture,
@@ -472,6 +481,88 @@ class RockRecipe(StormBase):
         )
         return self.getBuildRequest(job.job_id)
 
+    def requestBuildsFromJob(
+        self,
+        build_request,
+        channels=None,
+        architectures=None,
+        allow_failures=False,
+        logger=None,
+    ):
+        """See `IRockRecipe`."""
+        try:
+            rockcraft_data = removeSecurityProxy(
+                getUtility(IRockRecipeSet).getRockcraftYaml(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(
+                rockcraft_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 RockRecipeBuildAlreadyPending:
+                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 `IRockRecipe`."""
         return RockRecipeBuildRequest(self, job_id)
@@ -608,6 +699,53 @@ class RockRecipeSet:
             )
         )
 
+    def getRockcraftYaml(self, context, logger=None):
+        """See `IRockRecipeSet`."""
+        if IRockRecipe.providedBy(context):
+            recipe = context
+            source = context.git_ref
+        else:
+            recipe = None
+            source = context
+        if source is None:
+            raise CannotFetchRockcraftYaml("Rock source is not defined")
+        try:
+            path = "rockcraft.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 rockcraft.yaml in %s", source.unique_name
+                    )
+                raise MissingRockcraftYaml(source.unique_name)
+        except GitRepositoryScanFault as e:
+            msg = "Failed to get rockcraft.yaml from %s"
+            if logger is not None:
+                logger.exception(msg, source.unique_name)
+            raise CannotFetchRockcraftYaml(
+                "%s: %s" % (msg % source.unique_name, e)
+            )
+
+        try:
+            rockcraft_data = yaml.safe_load(blob)
+        except Exception as e:
+            # Don't bother logging parsing errors from user-supplied YAML.
+            raise CannotParseRockcraftYaml(
+                "Cannot parse rockcraft.yaml from %s: %s"
+                % (source.unique_name, e)
+            )
+
+        if not isinstance(rockcraft_data, dict):
+            raise CannotParseRockcraftYaml(
+                "The top level of rockcraft.yaml from %s is not a mapping"
+                % source.unique_name
+            )
+
+        return rockcraft_data
+
 >>>>>>> lib/lp/rocks/model/rockrecipe.py
     def findByGitRepository(self, repository, paths=None):
         """See `IRockRecipeSet`."""
diff --git a/lib/lp/rocks/model/rockrecipejob.py b/lib/lp/rocks/model/rockrecipejob.py
index b0211af..f21df96 100644
--- a/lib/lp/rocks/model/rockrecipejob.py
+++ b/lib/lp/rocks/model/rockrecipejob.py
@@ -20,6 +20,11 @@ from zope.interface import implementer, provider
 
 from lp.app.errors import NotFoundError
 from lp.registry.interfaces.person import IPersonSet
+from lp.rocks.interfaces.rockrecipe import (
+    CannotFetchRockcraftYaml,
+    CannotParseRockcraftYaml,
+    MissingRockcraftYaml,
+)
 from lp.rocks.interfaces.rockrecipejob import (
     IRockRecipeJob,
     IRockRecipeRequestBuildsJob,
@@ -156,6 +161,12 @@ class RockRecipeRequestBuildsJob(RockRecipeJobDerived):
 
     class_job_type = RockRecipeJobType.REQUEST_BUILDS
 
+    user_error_types = (
+        CannotParseRockcraftYaml,
+        MissingRockcraftYaml,
+    )
+    retry_error_types = (CannotFetchRockcraftYaml,)
+
     max_retries = 5
 
     config = config.IRockRecipeRequestBuildsJobSource
@@ -304,9 +315,13 @@ class RockRecipeRequestBuildsJob(RockRecipeJobDerived):
             )
             return
         try:
-            # XXX jugmac00 2024-09-06: Implement this once we have a
-            # RockRecipeBuild 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/rocks/tests/test_rockrecipe.py b/lib/lp/rocks/tests/test_rockrecipe.py
index 8f55598..50f8271 100644
--- a/lib/lp/rocks/tests/test_rockrecipe.py
+++ b/lib/lp/rocks/tests/test_rockrecipe.py
@@ -5,6 +5,9 @@
 
 <<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
 =======
+from textwrap import dedent
+
+import transaction
 from storm.locals import Store
 from testtools.matchers import (
     Equals,
@@ -20,6 +23,7 @@ from zope.security.proxy import removeSecurityProxy
 from lp.app.enums import InformationType
 <<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
 =======
+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 (
@@ -27,6 +31,7 @@ from lp.buildmaster.interfaces.processor import (
     ProcessorNotFound,
 )
 from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.code.tests.helpers import GitHostingFixture
 >>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
 from lp.rocks.interfaces.rockrecipe import (
     ROCK_RECIPE_ALLOW_CREATE,
@@ -220,6 +225,129 @@ class TestRockRecipe(TestCaseWithFactory):
             ),
         )
 
+    def makeRequestBuildsJob(
+        self, distro_series_version, arch_tags, git_ref=None
+    ):
+        recipe = self.factory.makeRockRecipe(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(IRockRecipeRequestBuildsJobSource).create(
+            recipe, recipe.owner.teamowner, {"rockcraft": "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(
+                    """\
+            bases:
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [sparc]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [i386]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [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="name: foo\n"))
+        self.useFixture(
+            GitHostingFixture(
+                blob=dedent(
+                    """\
+            bases:
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [sparc]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [i386]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [avr]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [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
+        )
+
 >>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     def test_delete_without_builds(self):
         # A rock recipe with no builds can be deleted.
diff --git a/lib/lp/rocks/tests/test_rockrecipejob.py b/lib/lp/rocks/tests/test_rockrecipejob.py
index f5da198..d3aee31 100644
--- a/lib/lp/rocks/tests/test_rockrecipejob.py
+++ b/lib/lp/rocks/tests/test_rockrecipejob.py
@@ -3,7 +3,28 @@
 
 """Tests for rock recipe jobs."""
 
-from lp.rocks.interfaces.rockrecipe import ROCK_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.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
+    CannotParseRockcraftYaml,
+)
 from lp.rocks.interfaces.rockrecipejob import (
     IRockRecipeJob,
     IRockRecipeRequestBuildsJob,
@@ -13,8 +34,15 @@ from lp.rocks.model.rockrecipejob import (
     RockRecipeJobType,
     RockRecipeRequestBuildsJob,
 )
+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
 
 
@@ -59,3 +87,192 @@ class TestRockRecipeRequestBuildsJob(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.makeRockRecipe(git_ref=git_ref)
+        expected_date_created = get_transaction_timestamp(IStore(recipe))
+        job = RockRecipeRequestBuildsJob.create(
+            recipe, recipe.registrant, channels={"core": "stable"}
+        )
+        rockcraft_yaml = dedent(
+            """\
+            bases:
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [avr2001]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [x32]
+            """
+        )
+        self.useFixture(GitHostingFixture(blob=rockcraft_yaml))
+        with dbuser(config.IRockRecipeRequestBuildsJobSource.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.makeRockRecipe(git_ref=git_ref)
+        expected_date_created = get_transaction_timestamp(IStore(recipe))
+        job = RockRecipeRequestBuildsJob.create(
+            recipe,
+            recipe.registrant,
+            channels={"core": "stable"},
+            architectures=["sparc64", "x32"],
+        )
+        rockcraft_yaml = dedent(
+            """\
+            bases:
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [avr2001]
+              - build-on:
+                  - name: ubuntu
+                    channel: "20.04"
+                    architectures: [x32]
+            """
+        )
+        self.useFixture(GitHostingFixture(blob=rockcraft_yaml))
+        with dbuser(config.IRockRecipeRequestBuildsJobSource.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.makeRockRecipe(git_ref=git_ref)
+        expected_date_created = get_transaction_timestamp(IStore(recipe))
+        job = RockRecipeRequestBuildsJob.create(
+            recipe, recipe.registrant, channels={"core": "stable"}
+        )
+        self.useFixture(GitHostingFixture()).getBlob.failure = (
+            CannotParseRockcraftYaml("Nonsense on stilts")
+        )
+        with dbuser(config.IRockRecipeRequestBuildsJobSource.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