← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:cibuild-stages-in-database into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:cibuild-stages-in-database into launchpad:master.

Commit message:
Store CIBuild stages in the DB for dispatch

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

buildd-manager previously had to fetch a CI build's configuration again from git in order to calculate the `jobs` argument to dispatch, which was poor design: we should instead store everything that buildd-manager needs in the database so that it can dispatch the job immediately.

This is incompatible with previously requested builds: without DB surgery, it won't be possible to dispatch those builds.  Since this feature is still rarely used and hasn't yet been announced, I think that's OK.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:cibuild-stages-in-database into launchpad:master.
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index 470cd95..68fa19f 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -21,6 +21,7 @@ from zope.schema import (
     Bool,
     Datetime,
     Int,
+    List,
     TextLine,
     )
 
@@ -115,6 +116,9 @@ class ICIBuildView(IPackageBuildView):
             "The date when the build completed or is estimated to complete."),
         readonly=True)
 
+    stages = List(
+        title=_("A list of stages in this build's configured pipeline."))
+
     def getConfiguration(logger=None):
         """Fetch a CI build's .launchpad.yaml from code hosting, if possible.
 
@@ -159,7 +163,7 @@ class ICIBuild(ICIBuildView, ICIBuildEdit, ICIBuildAdmin, IPackageBuild):
 class ICIBuildSet(ISpecificBuildFarmJobSource):
     """Utility to create and access `ICIBuild`s."""
 
-    def new(git_repository, commit_sha1, distro_arch_series,
+    def new(git_repository, commit_sha1, distro_arch_series, stages,
             date_created=DEFAULT):
         """Create an `ICIBuild`."""
 
@@ -171,7 +175,7 @@ class ICIBuildSet(ISpecificBuildFarmJobSource):
             these Git commit IDs.
         """
 
-    def requestBuild(git_repository, commit_sha1, distro_arch_series):
+    def requestBuild(git_repository, commit_sha1, distro_arch_series, stages):
         """Request a CI build.
 
         This checks that the architecture is allowed and that there isn't
@@ -181,6 +185,9 @@ class ICIBuildSet(ISpecificBuildFarmJobSource):
         :param commit_sha1: The Git commit ID for the new build.
         :param distro_arch_series: The `IDistroArchSeries` that the new
             build should run on.
+        :param stages: A list of stages in this build's pipeline according
+            to its `.launchpad.yaml`, each of which is a list of (job_name,
+            job_index) tuples.
         :raises CIBuildDisallowedArchitecture: if builds on
             `distro_arch_series` are not allowed.
         :raises CIBuildAlreadyRequested: if a matching build was already
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index afd4830..33d8255 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -11,6 +11,7 @@ from datetime import timedelta
 
 from lazr.lifecycle.event import ObjectCreatedEvent
 import pytz
+from storm.databases.postgres import JSON
 from storm.locals import (
     Bool,
     DateTime,
@@ -32,6 +33,7 @@ from lp.buildmaster.enums import (
     BuildQueueStatus,
     BuildStatus,
     )
+from lp.buildmaster.interfaces.builder import CannotBuild
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
 from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
 from lp.buildmaster.model.packagebuild import PackageBuildMixin
@@ -74,6 +76,22 @@ from lp.services.propertycache import cachedproperty
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
+def get_stages(configuration):
+    """Extract the job stages for this configuration."""
+    stages = []
+    if not configuration.pipeline:
+        raise CannotBuild("No pipeline stages defined")
+    for stage in configuration.pipeline:
+        jobs = []
+        for job_name in stage:
+            if job_name not in configuration.jobs:
+                raise CannotBuild("No job definition for %r" % job_name)
+            for i in range(len(configuration.jobs[job_name])):
+                jobs.append((job_name, i))
+        stages.append(jobs)
+    return stages
+
+
 def determine_DASes_to_build(configuration, logger=None):
     """Generate distroarchseries to build for this configuration."""
     architectures_by_series = {}
@@ -182,8 +200,10 @@ class CIBuild(PackageBuildMixin, StormBase):
     build_farm_job_id = Int(name="build_farm_job", allow_none=False)
     build_farm_job = Reference(build_farm_job_id, "BuildFarmJob.id")
 
+    _jobs = JSON(name="jobs", allow_none=True)
+
     def __init__(self, build_farm_job, git_repository, commit_sha1,
-                 distro_arch_series, processor, virtualized,
+                 distro_arch_series, processor, virtualized, stages,
                  date_created=DEFAULT):
         """Construct a `CIBuild`."""
         super().__init__()
@@ -193,6 +213,7 @@ class CIBuild(PackageBuildMixin, StormBase):
         self.distro_arch_series = distro_arch_series
         self.processor = processor
         self.virtualized = virtualized
+        self._jobs = {"stages": stages}
         self.date_created = date_created
         self.status = BuildStatus.NEEDSBUILD
 
@@ -359,6 +380,13 @@ class CIBuild(PackageBuildMixin, StormBase):
                 "%s: %s" % (msg % self.git_repository.unique_name, e))
         return parse_configuration(self.git_repository, blob)
 
+    @property
+    def stages(self):
+        """See `ICIBuild`."""
+        if self._jobs is None:
+            return []
+        return self._jobs.get("stages", [])
+
     def getFileByName(self, filename):
         """See `ICIBuild`."""
         if filename.endswith(".txt.gz"):
@@ -385,7 +413,7 @@ class CIBuild(PackageBuildMixin, StormBase):
 @implementer(ICIBuildSet)
 class CIBuildSet(SpecificBuildFarmJobSourceMixin):
 
-    def new(self, git_repository, commit_sha1, distro_arch_series,
+    def new(self, git_repository, commit_sha1, distro_arch_series, stages,
             date_created=DEFAULT):
         """See `ICIBuildSet`."""
         store = IMasterStore(CIBuild)
@@ -393,7 +421,7 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
             CIBuild.job_type, BuildStatus.NEEDSBUILD, date_created)
         cibuild = CIBuild(
             build_farm_job, git_repository, commit_sha1, distro_arch_series,
-            distro_arch_series.processor, virtualized=True,
+            distro_arch_series.processor, virtualized=True, stages=stages,
             date_created=date_created)
         store.add(cibuild)
         store.flush()
@@ -423,7 +451,8 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
             das.getChroot(pocket=pocket) is not None
             and self._isBuildableArchitectureAllowed(das))
 
-    def requestBuild(self, git_repository, commit_sha1, distro_arch_series):
+    def requestBuild(self, git_repository, commit_sha1, distro_arch_series,
+                     stages):
         """See `ICIBuildSet`."""
         pocket = PackagePublishingPocket.UPDATES
         if not self._isArchitectureAllowed(distro_arch_series, pocket):
@@ -437,20 +466,21 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
         if not result.is_empty():
             raise CIBuildAlreadyRequested
 
-        build = self.new(git_repository, commit_sha1, distro_arch_series)
+        build = self.new(
+            git_repository, commit_sha1, distro_arch_series, stages)
         build.queueBuild()
         notify(ObjectCreatedEvent(build))
         return build
 
     def _tryToRequestBuild(self, git_repository, commit_sha1, configuration,
-                           das, logger):
+                           das, stages, logger):
         try:
             if logger is not None:
                 logger.info(
                     "Requesting CI build for %s on %s/%s",
                     commit_sha1, das.distroseries.name, das.architecturetag,
                 )
-            build = self.requestBuild(git_repository, commit_sha1, das)
+            build = self.requestBuild(git_repository, commit_sha1, das, stages)
             # Create reports for each individual job in this build so that
             # they show up as pending in the web UI.  The job names
             # generated here should match those generated by
@@ -458,18 +488,16 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
             # lp.archiveuploader.ciupload looks for this report and attaches
             # artifacts to it.
             rsr_set = getUtility(IRevisionStatusReportSet)
-            for stage in configuration.pipeline:
-                for job_name in stage:
-                    for i in range(len(configuration.jobs.get(job_name, []))):
-                        # XXX cjwatson 2022-03-17: It would be better if we
-                        # could set some kind of meaningful description as
-                        # well.
-                        rsr_set.new(
-                            creator=git_repository.owner,
-                            title="%s:%s" % (job_name, i),
-                            git_repository=git_repository,
-                            commit_sha1=commit_sha1,
-                            ci_build=build)
+            for stage in stages:
+                for job_name, i in stage:
+                    # XXX cjwatson 2022-03-17: It would be better if we
+                    # could set some kind of meaningful description as well.
+                    rsr_set.new(
+                        creator=git_repository.owner,
+                        title="%s:%s" % (job_name, i),
+                        git_repository=git_repository,
+                        commit_sha1=commit_sha1,
+                        ci_build=build)
         except CIBuildAlreadyRequested:
             pass
         except Exception as e:
@@ -498,9 +526,18 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
                 if logger is not None:
                     logger.error(e)
                 continue
+            try:
+                stages = get_stages(configuration)
+            except CannotBuild as e:
+                if logger is not None:
+                    logger.error(
+                        "Failed to request CI builds for %s: %s",
+                        commit["sha1"], e)
+                continue
             for das in determine_DASes_to_build(configuration, logger=logger):
                 self._tryToRequestBuild(
-                    git_repository, commit["sha1"], configuration, das, logger)
+                    git_repository, commit["sha1"], configuration, das, stages,
+                    logger)
 
     def getByID(self, build_id):
         """See `ISpecificBuildFarmJobSource`."""
diff --git a/lib/lp/code/model/cibuildbehaviour.py b/lib/lp/code/model/cibuildbehaviour.py
index 4f9d92e..29c91cf 100644
--- a/lib/lp/code/model/cibuildbehaviour.py
+++ b/lib/lp/code/model/cibuildbehaviour.py
@@ -11,9 +11,9 @@ import json
 import os
 
 from twisted.internet import defer
-from twisted.internet.threads import deferToThread
 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
@@ -25,11 +25,6 @@ from lp.buildmaster.model.buildfarmjobbehaviour import (
     BuildFarmJobBehaviourBase,
     )
 from lp.code.interfaces.cibuild import ICIBuild
-from lp.services.timeout import default_timeout
-from lp.services.webapp.interaction import (
-    ANONYMOUS,
-    setupInteraction,
-    )
 from lp.soyuz.adapters.archivedependencies import (
     get_sources_list_for_building,
     )
@@ -71,49 +66,17 @@ class CIBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
     def extraBuildArgs(self, logger=None):
         """Return extra builder arguments for this build."""
         build = self.build
-        # Preload the build's repository so that it can be accessed from
-        # another thread.
-        build.git_repository.id
-
-        # XXX cjwatson 2022-03-24: Work around a design error.  We ought to
-        # have arranged to store the relevant bits of the configuration
-        # (i.e. `stages` below) in the database so that we don't need to
-        # fetch it again here.  It isn't safe to run blocking network
-        # requests in buildd-manager's main thread, since that would block
-        # the Twisted reactor; defer the request to a thread for now, but
-        # we'll need to work out a better fix once we have time.
-        def get_configuration():
-            setupInteraction(ANONYMOUS)
-            with default_timeout(15.0):
-                try:
-                    return build.getConfiguration(logger=logger)
-                except Exception as e:
-                    raise CannotBuild(str(e))
-
-        configuration = yield deferToThread(get_configuration)
-        stages = []
-        if not configuration.pipeline:
+        if not build.stages:
             raise CannotBuild(
-                "No jobs defined for %s:%s" %
+                "No stages defined for %s:%s" %
                 (build.git_repository.unique_name, build.commit_sha1))
-        for stage in configuration.pipeline:
-            jobs = []
-            for job_name in stage:
-                if job_name not in configuration.jobs:
-                    raise CannotBuild(
-                        "Job '%s' in pipeline for %s:%s but not in jobs" %
-                        (job_name,
-                         build.git_repository.unique_name, build.commit_sha1))
-                for i in range(len(configuration.jobs[job_name])):
-                    jobs.append((job_name, i))
-            stages.append(jobs)
 
         args = yield super().extraBuildArgs(logger=logger)
         yield self.addProxyArgs(args)
         args["archives"], args["trusted_keys"] = (
             yield get_sources_list_for_building(
                 self, build.distro_arch_series, None, logger=logger))
-        args["jobs"] = stages
+        args["jobs"] = removeSecurityProxy(build.stages)
         args["git_repository"] = build.git_repository.git_https_url
         args["git_path"] = build.commit_sha1
         args["private"] = build.is_private
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index e49e23e..d44a2fd 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -405,15 +405,17 @@ class TestCIBuildSet(TestCaseWithFactory):
         repository = self.factory.makeGitRepository()
         commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
         das = self.factory.makeBuildableDistroArchSeries()
+        stages = [[("build", 0)]]
 
         build = getUtility(ICIBuildSet).requestBuild(
-            repository, commit_sha1, das)
+            repository, commit_sha1, das, stages)
 
         self.assertTrue(ICIBuild.providedBy(build))
         self.assertThat(build, MatchesStructure.byEquality(
             git_repository=repository,
             commit_sha1=commit_sha1,
             distro_arch_series=das,
+            stages=stages,
             status=BuildStatus.NEEDSBUILD,
             ))
         store = Store.of(build)
@@ -432,7 +434,7 @@ class TestCIBuildSet(TestCaseWithFactory):
         commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
         das = self.factory.makeBuildableDistroArchSeries()
         build = getUtility(ICIBuildSet).requestBuild(
-            repository, commit_sha1, das)
+            repository, commit_sha1, das, [[("test", 0)]])
         queue_record = build.buildqueue_record
         queue_record.score()
         self.assertEqual(2600, queue_record.lastscore)
@@ -447,19 +449,19 @@ class TestCIBuildSet(TestCaseWithFactory):
                 distroseries=distro_series)
             for _ in range(2)]
         old_build = getUtility(ICIBuildSet).requestBuild(
-            repository, commit_sha1, arches[0])
+            repository, commit_sha1, arches[0], [[("test", 0)]])
         self.assertRaises(
             CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild,
-            repository, commit_sha1, arches[0])
+            repository, commit_sha1, arches[0], [[("test", 0)]])
         # We can build for a different distroarchseries.
         getUtility(ICIBuildSet).requestBuild(
-            repository, commit_sha1, arches[1])
+            repository, commit_sha1, arches[1], [[("test", 0)]])
         # Changing the status of the old build does not allow a new build.
         old_build.updateStatus(BuildStatus.BUILDING)
         old_build.updateStatus(BuildStatus.FULLYBUILT)
         self.assertRaises(
             CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild,
-            repository, commit_sha1, arches[0])
+            repository, commit_sha1, arches[0], [[("test", 0)]])
 
     def test_requestBuild_virtualization(self):
         # New builds are virtualized.
@@ -471,7 +473,7 @@ class TestCIBuildSet(TestCaseWithFactory):
                 distroseries=distro_series, supports_virtualized=True,
                 supports_nonvirtualized=proc_nonvirt)
             build = getUtility(ICIBuildSet).requestBuild(
-                repository, commit_sha1, das)
+                repository, commit_sha1, das, [[("test", 0)]])
             self.assertTrue(build.virtualized)
 
     def test_requestBuild_nonvirtualized(self):
@@ -484,7 +486,8 @@ class TestCIBuildSet(TestCaseWithFactory):
             supports_nonvirtualized=True)
         self.assertRaises(
             CIBuildDisallowedArchitecture,
-            getUtility(ICIBuildSet).requestBuild, repository, commit_sha1, das)
+            getUtility(ICIBuildSet).requestBuild,
+            repository, commit_sha1, das, [[("test", 0)]])
 
     def test_requestBuildsForRefs_triggers_builds(self):
         ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
@@ -541,6 +544,8 @@ class TestCIBuildSet(TestCaseWithFactory):
         self.assertEqual(ref.commit_sha1, build.commit_sha1)
         self.assertEqual("focal", build.distro_arch_series.distroseries.name)
         self.assertEqual("amd64", build.distro_arch_series.architecturetag)
+        self.assertEqual(
+            [[("build", 0), ("build", 1)], [("test", 0)]], build.stages)
         self.assertThat(reports, MatchesSetwise(*(
             MatchesStructure.byEquality(
                 creator=repository.owner,
@@ -644,6 +649,78 @@ class TestCIBuildSet(TestCaseWithFactory):
             logger.getLogBuffer()
         )
 
+    def test_requestBuildsForRefs_no_pipeline_defined(self):
+        # If the job's configuration does not define any pipeline stages,
+        # requestBuildsForRefs logs an error.
+        configuration = b"pipeline: []\njobs: {}\n"
+        [ref] = self.factory.makeGitRefs()
+        encoded_commit_json = {
+            "sha1": ref.commit_sha1,
+            "blobs": {".launchpad.yaml": configuration},
+        }
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[encoded_commit_json])
+        )
+        logger = BufferLogger()
+
+        getUtility(ICIBuildSet).requestBuildsForRefs(
+            ref.repository, [ref.path], logger)
+
+        self.assertEqual(
+            [((ref.repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"], "logger": logger})],
+            hosting_fixture.getCommits.calls
+        )
+        self.assertTrue(
+            getUtility(ICIBuildSet).findByGitRepository(
+                ref.repository).is_empty()
+        )
+        self.assertTrue(
+            getUtility(IRevisionStatusReportSet).findByRepository(
+                ref.repository).is_empty()
+        )
+        self.assertEqual(
+            "ERROR Failed to request CI builds for %s: "
+            "No pipeline stages defined\n" % ref.commit_sha1,
+            logger.getLogBuffer()
+        )
+
+    def test_requestBuildsForRefs_undefined_job(self):
+        # If the job's configuration has a pipeline that defines a job not
+        # in the jobs matrix, requestBuildsForRefs logs an error.
+        configuration = b"pipeline: [test]\njobs: {}\n"
+        [ref] = self.factory.makeGitRefs()
+        encoded_commit_json = {
+            "sha1": ref.commit_sha1,
+            "blobs": {".launchpad.yaml": configuration},
+        }
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[encoded_commit_json])
+        )
+        logger = BufferLogger()
+
+        getUtility(ICIBuildSet).requestBuildsForRefs(
+            ref.repository, [ref.path], logger)
+
+        self.assertEqual(
+            [((ref.repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"], "logger": logger})],
+            hosting_fixture.getCommits.calls
+        )
+        self.assertTrue(
+            getUtility(ICIBuildSet).findByGitRepository(
+                ref.repository).is_empty()
+        )
+        self.assertTrue(
+            getUtility(IRevisionStatusReportSet).findByRepository(
+                ref.repository).is_empty()
+        )
+        self.assertEqual(
+            "ERROR Failed to request CI builds for %s: "
+            "No job definition for 'test'\n" % ref.commit_sha1,
+            logger.getLogBuffer()
+        )
+
     def test_requestBuildsForRefs_build_already_scheduled(self):
         ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
         series = self.factory.makeDistroSeries(
diff --git a/lib/lp/code/model/tests/test_cibuildbehaviour.py b/lib/lp/code/model/tests/test_cibuildbehaviour.py
index 091c7be..190622a 100644
--- a/lib/lp/code/model/tests/test_cibuildbehaviour.py
+++ b/lib/lp/code/model/tests/test_cibuildbehaviour.py
@@ -8,7 +8,6 @@ from datetime import datetime
 import json
 import os.path
 import re
-from textwrap import dedent
 import time
 from urllib.parse import urlsplit
 import uuid
@@ -60,9 +59,7 @@ from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
     TestHandleStatusMixin,
     TestVerifySuccessfulBuildMixin,
     )
-from lp.code.errors import GitRepositoryBlobNotFound
 from lp.code.model.cibuildbehaviour import CIBuildBehaviour
-from lp.code.tests.helpers import GitHostingFixture
 from lp.services.config import config
 from lp.services.log.logger import (
     BufferLogger,
@@ -179,7 +176,7 @@ class TestAsyncCIBuildBehaviour(StatsMixin, TestCIBuildBehaviourBase):
         self.addCleanup(shut_down_default_process_pool)
         self.setUpStats()
 
-    def makeJob(self, configuration=_unset, **kwargs):
+    def makeJob(self, **kwargs):
         # We need a builder in these tests, in order that requesting a proxy
         # token can piggyback on its reactor and pool.
         job = super().makeJob(**kwargs)
@@ -188,24 +185,6 @@ class TestAsyncCIBuildBehaviour(StatsMixin, TestCIBuildBehaviourBase):
         worker = self.useFixture(WorkerTestHelpers()).getClientWorker()
         job.setBuilder(builder, worker)
         self.addCleanup(worker.pool.closeCachedConnections)
-        if configuration is _unset:
-            # Skeleton configuration defining a single job.
-            configuration = dedent("""\
-                pipeline:
-                    - [test]
-                jobs:
-                    test:
-                        series: {}
-                        architectures: [{}]
-                """.format(
-                    job.build.distro_arch_series.distroseries.name,
-                    job.build.distro_arch_series.architecturetag)).encode()
-        hosting_fixture = self.useFixture(
-            GitHostingFixture(blob=configuration, enforce_timeout=True))
-        if configuration is None:
-            hosting_fixture.getBlob.failure = GitRepositoryBlobNotFound(
-                job.build.git_repository.getInternalPath(), ".launchpad.yaml",
-                rev=job.build.commit_sha1)
         return job
 
     @defer.inlineCallbacks
@@ -261,7 +240,7 @@ class TestAsyncCIBuildBehaviour(StatsMixin, TestCIBuildBehaviourBase):
     def test_extraBuildArgs_git(self):
         # extraBuildArgs returns appropriate arguments if asked to build a
         # job for a Git commit.
-        job = self.makeJob()
+        job = self.makeJob(stages=[[("test", 0)]])
         expected_archives, expected_trusted_keys = (
             yield get_sources_list_for_building(
                 job, job.build.distro_arch_series, None))
@@ -277,7 +256,7 @@ class TestAsyncCIBuildBehaviour(StatsMixin, TestCIBuildBehaviourBase):
             "fast_cleanup": Is(True),
             "git_path": Equals(job.build.commit_sha1),
             "git_repository": Equals(job.build.git_repository.git_https_url),
-            "jobs": Equals([[("test", 0)]]),
+            "jobs": Equals([[["test", 0]]]),
             "private": Is(False),
             "proxy_url": ProxyURLMatcher(job, self.now),
             "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
@@ -340,34 +319,11 @@ class TestAsyncCIBuildBehaviour(StatsMixin, TestCIBuildBehaviourBase):
             build_request[4]["proxy_url"], ProxyURLMatcher(job, self.now))
 
     @defer.inlineCallbacks
-    def test_composeBuildRequest_unparseable(self):
-        # If the job's configuration file fails to parse,
-        # composeBuildRequest raises CannotBuild.
-        job = self.makeJob(configuration=b"")
-        expected_exception_msg = (
-            r"Cannot parse \.launchpad\.yaml from .*: "
-            r"Empty configuration file")
-        with ExpectedException(CannotBuild, expected_exception_msg):
-            yield job.composeBuildRequest(None)
-
-    @defer.inlineCallbacks
-    def test_composeBuildRequest_no_jobs_defined(self):
-        # If the job's configuration does not define any jobs,
-        # composeBuildRequest raises CannotBuild.
-        job = self.makeJob(configuration=b"pipeline: []\njobs: {}\n")
-        expected_exception_msg = re.escape(
-            "No jobs defined for %s:%s" % (
-                job.build.git_repository.unique_name, job.build.commit_sha1))
-        with ExpectedException(CannotBuild, expected_exception_msg):
-            yield job.composeBuildRequest(None)
-
-    @defer.inlineCallbacks
-    def test_composeBuildRequest_undefined_job(self):
-        # If the job's configuration has a pipeline that defines a job not
-        # in the jobs matrix, composeBuildRequest raises CannotBuild.
-        job = self.makeJob(configuration=b"pipeline: [test]\njobs: {}\n")
+    def test_composeBuildRequest_no_stages_defined(self):
+        # If the build has no stages, composeBuildRequest raises CannotBuild.
+        job = self.makeJob(stages=[])
         expected_exception_msg = re.escape(
-            "Job 'test' in pipeline for %s:%s but not in jobs" % (
+            "No stages defined for %s:%s" % (
                 job.build.git_repository.unique_name, job.build.commit_sha1))
         with ExpectedException(CannotBuild, expected_exception_msg):
             yield job.composeBuildRequest(None)
@@ -416,15 +372,6 @@ class MakeCIBuildMixin:
 
     def makeBuild(self):
         build = self.factory.makeCIBuild(status=BuildStatus.BUILDING)
-        das = build.distro_arch_series
-        self.useFixture(GitHostingFixture(blob=dedent("""\
-            pipeline:
-                - [test]
-            jobs:
-                test:
-                    series: {}
-                    architectures: [{}]
-            """.format(das.distroseries.name, das.architecturetag)).encode()))
         build.queueBuild()
         return build
 
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 7c79147..f4162b1 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -5353,7 +5353,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             processors=processors, date_created=date_created)
 
     def makeCIBuild(self, git_repository=None, commit_sha1=None,
-                    distro_arch_series=None, date_created=DEFAULT,
+                    distro_arch_series=None, stages=None, date_created=DEFAULT,
                     status=BuildStatus.NEEDSBUILD, builder=None,
                     duration=None):
         """Make a new `CIBuild`."""
@@ -5363,8 +5363,10 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             commit_sha1 = hashlib.sha1(self.getUniqueBytes()).hexdigest()
         if distro_arch_series is None:
             distro_arch_series = self.makeDistroArchSeries()
+        if stages is None:
+            stages = [[("test", 0)]]
         build = getUtility(ICIBuildSet).new(
-            git_repository, commit_sha1, distro_arch_series,
+            git_repository, commit_sha1, distro_arch_series, stages,
             date_created=date_created)
         if duration is not None:
             removeSecurityProxy(build).updateStatus(