Merge ~pelpsi/launchpad:duplicate_ci_jobs into launchpad:master

Proposed by Simone Pelosi
Status: Merged
Approved by: Simone Pelosi
Approved revision: b1007d068d587970912f306dae6378dda7a1736f
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pelpsi/launchpad:duplicate_ci_jobs
Merge into: launchpad:master
Diff against target: 383 lines (+290/-8)
2 files modified
lib/lp/code/model/cibuild.py (+37/-8)
lib/lp/code/model/tests/test_cibuild.py (+253/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+440534@code.launchpad.net

Commit message

Stages now depend on architecture

Stages now is a dictionary where each key corresponds
to the architecture and the value is the old stages array.
Thus we'll have number of build farm jobs equals to the
number of architectures.
If an arch is present in the pipeline but missing in previous
stages an error is raised.

LP: #1999591

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Looks like a good start, but I'd definitely be more comfortable with a more detailed test that spelled out the expected output more explicitly, since some of the code doesn't look completely correct.

review: Needs Fixing
Revision history for this message
Colin Watson (cjwatson) wrote :
Download full text (4.7 KiB)

I see that you had to make quite extensive changes to account for one of my earlier comments asking if the structure of `stages` was right. For the record, it's totally fine to push back on me if I ask this sort of question and dealing with it seems to have massive fallout. Looking at this, I think we need to step back and think about it a bit more.

The problem with what we've ended up with here is that the format of the data structure in `CIBuild.stages` (as opposed to whatever intermediate objects we use in the process of constructing that) has changed, and I don't think that can be the right solution to this problem. That data structure is passed to the builder, so changing its format will almost certainly break the ability to run CI jobs on the Launchpad build farm.

The structure that ends up in `CIBuild.stages` is more or less a cleaned-up version of `pipeline` from https://lpci.readthedocs.io/en/latest/configuration.html. It's supposed to be a list of stages, each stage being a list of jobs, and each job being a tuple of (job_name, job_index). Within a stage, each job is executed (the specification allows for this to be in parallel, but in practice right now it's in series); all jobs in the stage are run, but the stage fails if any of those jobs fail. Each stage is executed in sequence, with subsequent stages only executing if previous stages succeeded. We need to preserve something compatible with this, as it's part of the build dispatch protocol; and we need to make sure that the structure continues to reflect what's in the `pipeline` configuration we've read, even if we're filtering it by series.

But there's another problem here. As mentioned above, the intended semantics are that jobs in later pipeline stages aren't run if earlier stages fail, and it's not really obvious how that works with multi-series pipelines given the current arrangements. Consider this hypothetical pipeline to build an artifact once and then test it on multiple series (which might make sense for artifacts like snaps that are generally supposed to be installable on multiple series, but that might have non-trivial interactions with the host system):

  pipeline:
    - build-snap
    - test-snap
  jobs:
    build-snap:
      series: focal
      architectures: [amd64, arm64]
      build-snaps: [snapcraft]
      run: snapcraft
    test-snap:
      matrix:
        - series: bionic
          architectures: [amd64]
        - series: focal
          architectures: [amd64, arm64]
        - series: jammy
          architectures: [amd64, arm64]
      run: ...

This needs to run `build-snap` on focal first, for each of amd64 and arm64; and then if and only if that succeeds, it needs to run `test-snap` on each of bionic (only on amd64, for the sake of a more interesting example), focal, and jammy. If we dispatch entirely separate jobs to the build farm that only contain the CI jobs for each of those releases, this isn't going to work as intended.

What I think `CIBuild.requestBuildsForRefs` needs to do here is to work out a list of build farm jobs it needs to request, each of which will run an appropriate subset of the pipeline depending on the architecture. E...

Read more...

review: Needs Fixing
Revision history for this message
Simone Pelosi (pelpsi) wrote :
Download full text (5.1 KiB)

> I see that you had to make quite extensive changes to account for one of my
> earlier comments asking if the structure of `stages` was right. For the
> record, it's totally fine to push back on me if I ask this sort of question
> and dealing with it seems to have massive fallout. Looking at this, I think
> we need to step back and think about it a bit more.
>
> The problem with what we've ended up with here is that the format of the data
> structure in `CIBuild.stages` (as opposed to whatever intermediate objects we
> use in the process of constructing that) has changed, and I don't think that
> can be the right solution to this problem. That data structure is passed to
> the builder, so changing its format will almost certainly break the ability to
> run CI jobs on the Launchpad build farm.
>
> The structure that ends up in `CIBuild.stages` is more or less a cleaned-up
> version of `pipeline` from
> https://lpci.readthedocs.io/en/latest/configuration.html. It's supposed to be
> a list of stages, each stage being a list of jobs, and each job being a tuple
> of (job_name, job_index). Within a stage, each job is executed (the
> specification allows for this to be in parallel, but in practice right now
> it's in series); all jobs in the stage are run, but the stage fails if any of
> those jobs fail. Each stage is executed in sequence, with subsequent stages
> only executing if previous stages succeeded. We need to preserve something
> compatible with this, as it's part of the build dispatch protocol; and we need
> to make sure that the structure continues to reflect what's in the `pipeline`
> configuration we've read, even if we're filtering it by series.
>
> But there's another problem here. As mentioned above, the intended semantics
> are that jobs in later pipeline stages aren't run if earlier stages fail, and
> it's not really obvious how that works with multi-series pipelines given the
> current arrangements. Consider this hypothetical pipeline to build an
> artifact once and then test it on multiple series (which might make sense for
> artifacts like snaps that are generally supposed to be installable on multiple
> series, but that might have non-trivial interactions with the host system):
>
> pipeline:
> - build-snap
> - test-snap
> jobs:
> build-snap:
> series: focal
> architectures: [amd64, arm64]
> build-snaps: [snapcraft]
> run: snapcraft
> test-snap:
> matrix:
> - series: bionic
> architectures: [amd64]
> - series: focal
> architectures: [amd64, arm64]
> - series: jammy
> architectures: [amd64, arm64]
> run: ...
>
> This needs to run `build-snap` on focal first, for each of amd64 and arm64;
> and then if and only if that succeeds, it needs to run `test-snap` on each of
> bionic (only on amd64, for the sake of a more interesting example), focal, and
> jammy. If we dispatch entirely separate jobs to the build farm that only
> contain the CI jobs for each of those releases, this isn't going to work as
> intended.
>
> What I think `CIBuild.requestBuildsForRefs` needs to do here is to work out a
> list of build f...

Read more...

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Simone Pelosi (pelpsi) wrote :

Updated and commits squashed!

Revision history for this message
Jürgen Gmach (jugmac00) wrote :

For the future... when there are change requests, it is way easier to review the applied changes when you just push a new commit, and then do a squash after approval.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Simone Pelosi (pelpsi) wrote :

Squashed into one commit

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
2index 48e171a..fb29c2e 100644
3--- a/lib/lp/code/model/cibuild.py
4+++ b/lib/lp/code/model/cibuild.py
5@@ -7,6 +7,7 @@ __all__ = [
6 "CIBuild",
7 ]
8
9+from collections import defaultdict
10 from copy import copy
11 from datetime import timedelta, timezone
12 from operator import itemgetter
13@@ -22,6 +23,7 @@ from zope.security.proxy import removeSecurityProxy
14
15 from lp.app.errors import NotFoundError
16 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
17+from lp.archivepublisher.debversion import Version
18 from lp.buildmaster.enums import (
19 BuildFarmJobType,
20 BuildQueueStatus,
21@@ -86,17 +88,34 @@ from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
22
23 def get_stages(configuration):
24 """Extract the job stages for this configuration."""
25- stages = []
26+ stages = defaultdict(list)
27 if not configuration.pipeline:
28 raise CannotBuild("No pipeline stages defined")
29+ previous_job = ""
30 for stage in configuration.pipeline:
31- jobs = []
32 for job_name in stage:
33+ jobs = defaultdict(list)
34 if job_name not in configuration.jobs:
35 raise CannotBuild("No job definition for %r" % job_name)
36 for i in range(len(configuration.jobs[job_name])):
37- jobs.append((job_name, i))
38- stages.append(jobs)
39+ for arch in configuration.jobs[job_name][i]["architectures"]:
40+ # Making sure that the previous job is present
41+ # in the pipeline for a given arch.
42+ if previous_job != "":
43+ if (
44+ len(stages[arch]) == 0
45+ or previous_job not in stages[arch][-1][0]
46+ ):
47+ raise CannotBuild(
48+ f"Job {job_name} would run on {arch},"
49+ + f"but the previous job {previous_job}"
50+ + "in the same pipeline would not"
51+ )
52+ jobs[arch].append((job_name, i))
53+
54+ for arch, value in jobs.items():
55+ stages[arch].append(value)
56+ previous_job = job_name
57 return stages
58
59
60@@ -118,16 +137,25 @@ def determine_DASes_to_build(configuration, logger=None):
61 # the .launchpad.yaml format doesn't currently support other
62 # distributions (although nor does the Launchpad build farm).
63 distribution = getUtility(ILaunchpadCelebrities).ubuntu
64- for series_name, architecture_names in architectures_by_series.items():
65+
66+ series_list = []
67+ for series_name in architectures_by_series.keys():
68 try:
69 series = distribution[series_name]
70+ series_list.append(series)
71 except NotFoundError:
72 if logger is not None:
73 logger.error("Unknown Ubuntu series name %s" % series_name)
74 continue
75+
76+ if len(series_list) != 0:
77+ latest_series = max(series_list, key=lambda x: Version(x.version))
78 architectures = {
79- das.architecturetag: das for das in series.buildable_architectures
80+ das.architecturetag: das
81+ for das in latest_series.buildable_architectures
82 }
83+
84+ architecture_names = architectures_by_series[latest_series.name]
85 for architecture_name in architecture_names:
86 try:
87 das = architectures[architecture_name]
88@@ -135,7 +163,7 @@ def determine_DASes_to_build(configuration, logger=None):
89 if logger is not None:
90 logger.error(
91 "%s is not a buildable architecture name in "
92- "Ubuntu %s" % (architecture_name, series_name)
93+ "Ubuntu %s" % (architecture_name, latest_series.name)
94 )
95 continue
96 yield das
97@@ -776,13 +804,14 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
98 e,
99 )
100 continue
101+
102 for das in determine_DASes_to_build(configuration, logger=logger):
103 self._tryToRequestBuild(
104 git_repository,
105 commit["sha1"],
106 configuration,
107 das,
108- stages,
109+ stages[das.architecturetag],
110 logger,
111 )
112
113diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
114index a1f2360..32aa183 100644
115--- a/lib/lp/code/model/tests/test_cibuild.py
116+++ b/lib/lp/code/model/tests/test_cibuild.py
117@@ -884,6 +884,65 @@ class TestCIBuildSet(TestCaseWithFactory):
118 )
119 self.assertEqual(("gpu",), build.builder_constraints)
120
121+ def test_requestBuildsForRefs_missing_archs_previous_stages(self):
122+ logger = BufferLogger()
123+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
124+ series = self.factory.makeDistroSeries(
125+ distribution=ubuntu,
126+ name="focal",
127+ )
128+ self.factory.makeBuildableDistroArchSeries(
129+ distroseries=series, architecturetag="amd64"
130+ )
131+ self.factory.makeBuildableDistroArchSeries(
132+ distroseries=series, architecturetag="arm64"
133+ )
134+ configuration = dedent(
135+ """\
136+ pipeline:
137+ - build
138+ - lint
139+ - test
140+
141+ jobs:
142+ build:
143+ matrix:
144+ - series: focal
145+ architectures: amd64
146+ - series: bionic
147+ architectures: arm64
148+ - series: focal
149+ architectures: arm64
150+ run: pyproject-build
151+ lint:
152+ series: focal
153+ architectures: arm64
154+ run: echo hello world >output
155+ test:
156+ series: focal
157+ architectures: amd64
158+ run: echo hello world >output
159+ """
160+ ).encode()
161+ repository = self.factory.makeGitRepository()
162+ ref_paths = ["refs/heads/master"]
163+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
164+ encoded_commit_json = {
165+ "sha1": ref.commit_sha1,
166+ "blobs": {".launchpad.yaml": configuration},
167+ }
168+ self.useFixture(GitHostingFixture(commits=[encoded_commit_json]))
169+
170+ getUtility(ICIBuildSet).requestBuildsForRefs(
171+ repository, ref_paths, logger
172+ )
173+ self.assertEqual(
174+ "ERROR Failed to request CI builds for %s: "
175+ "Job test would run on amd64,but the previous job "
176+ "lintin the same pipeline would not\n" % ref.commit_sha1,
177+ logger.getLogBuffer(),
178+ )
179+
180 def test_requestBuildsForRefs_triggers_builds(self):
181 ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
182 series = self.factory.makeDistroSeries(
183@@ -964,6 +1023,200 @@ class TestCIBuildSet(TestCaseWithFactory):
184 ),
185 )
186
187+ def test_requestBuildsForRefs_multiple_architectures(self):
188+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
189+ bionic = self.factory.makeDistroSeries(
190+ distribution=ubuntu,
191+ name="bionic",
192+ )
193+ focal = self.factory.makeDistroSeries(
194+ distribution=ubuntu,
195+ name="focal",
196+ )
197+ jammy = self.factory.makeDistroSeries(
198+ distribution=ubuntu,
199+ name="jammy",
200+ )
201+ for series in [bionic, focal, jammy]:
202+ self.factory.makeBuildableDistroArchSeries(
203+ distroseries=series, architecturetag="amd64"
204+ )
205+ self.factory.makeBuildableDistroArchSeries(
206+ distroseries=series, architecturetag="arm64"
207+ )
208+ configuration = dedent(
209+ """\
210+ pipeline:
211+ - build
212+ - test
213+
214+ jobs:
215+ build:
216+ series: focal
217+ architectures: [amd64, arm64]
218+ run: echo build
219+ test:
220+ matrix:
221+ - series: bionic
222+ architectures: [amd64]
223+ - series: focal
224+ architectures: [amd64, arm64]
225+ - series: jammy
226+ architectures: [amd64, arm64]
227+ run: echo test
228+ """
229+ ).encode()
230+ repository = self.factory.makeGitRepository()
231+ ref_paths = ["refs/heads/master"]
232+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
233+ encoded_commit_json = {
234+ "sha1": ref.commit_sha1,
235+ "blobs": {".launchpad.yaml": configuration},
236+ }
237+ self.useFixture(GitHostingFixture(commits=[encoded_commit_json]))
238+
239+ getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
240+
241+ builds = getUtility(ICIBuildSet).findByGitRepository(repository)
242+ reports = list(
243+ getUtility(IRevisionStatusReportSet).findByRepository(repository)
244+ )
245+
246+ jammy_test_arm64 = None
247+ jammy_test_amd64 = None
248+
249+ self.assertEqual(7, len(reports))
250+
251+ for build in builds:
252+ self.assertEqual(ref.commit_sha1, build.commit_sha1)
253+
254+ if build.distro_arch_series.distroseries.name == "jammy":
255+ if build.distro_arch_series.architecturetag == "amd64":
256+ jammy_test_amd64 = build
257+ else:
258+ jammy_test_arm64 = build
259+
260+ self.assertIsNot(None, jammy_test_arm64)
261+ self.assertIsNot(None, jammy_test_amd64)
262+
263+ self.assertEqual(
264+ [[("build", 0)], [("test", 1), ("test", 2)]],
265+ jammy_test_arm64.stages,
266+ )
267+ self.assertEqual(
268+ [[("build", 0)], [("test", 0), ("test", 1), ("test", 2)]],
269+ jammy_test_amd64.stages,
270+ )
271+ self.assertThat(
272+ reports,
273+ MatchesSetwise(
274+ *(
275+ MatchesStructure.byEquality(
276+ creator=repository.owner,
277+ title=title,
278+ git_repository=repository,
279+ commit_sha1=ref.commit_sha1,
280+ ci_build=build,
281+ )
282+ for title, build in [
283+ # amd
284+ ("build:0", jammy_test_amd64),
285+ ("test:0", jammy_test_amd64),
286+ ("test:1", jammy_test_amd64),
287+ ("test:2", jammy_test_amd64),
288+ # arm
289+ ("build:0", jammy_test_arm64),
290+ ("test:1", jammy_test_arm64),
291+ ("test:2", jammy_test_arm64),
292+ ]
293+ )
294+ ),
295+ )
296+
297+ def test_requestBuildsForRefs_creates_correct_amount_of_builds(self):
298+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
299+ focal = self.factory.makeDistroSeries(
300+ distribution=ubuntu, name="focal", version="20.04"
301+ )
302+ jammy = self.factory.makeDistroSeries(
303+ distribution=ubuntu, name="jammy", version="22.04"
304+ )
305+ for series in [focal, jammy]:
306+ self.factory.makeBuildableDistroArchSeries(
307+ distroseries=series, architecturetag="amd64"
308+ )
309+ configuration = dedent(
310+ """\
311+ pipeline:
312+ - build
313+ - test
314+
315+ jobs:
316+ build:
317+ series: jammy
318+ architectures: amd64
319+ run: echo jammy
320+ test:
321+ series: focal
322+ architectures: amd64
323+ run: echo focal
324+ """
325+ ).encode()
326+ repository = self.factory.makeGitRepository()
327+ ref_paths = ["refs/heads/master"]
328+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
329+ encoded_commit_json = {
330+ "sha1": ref.commit_sha1,
331+ "blobs": {".launchpad.yaml": configuration},
332+ }
333+ self.useFixture(GitHostingFixture(commits=[encoded_commit_json]))
334+
335+ getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
336+
337+ builds = list(getUtility(ICIBuildSet).findByGitRepository(repository))
338+ reports = list(
339+ getUtility(IRevisionStatusReportSet).findByRepository(repository)
340+ )
341+ self.assertEqual(2, len(reports))
342+
343+ self.assertEqual(1, len(builds))
344+
345+ jammy_build = builds[0]
346+
347+ self.assertEqual(
348+ "jammy", reports[0].ci_build.distro_arch_series.distroseries.name
349+ )
350+ self.assertEqual(
351+ "jammy", reports[1].ci_build.distro_arch_series.distroseries.name
352+ )
353+
354+ self.assertEqual([[("build", 0)], [("test", 0)]], jammy_build.stages)
355+
356+ # build:0 and test:0 are dispatched as part of the same build farm job
357+ # in order that test:0 can wait for build:0 to succeed.
358+ # The build farm job must have a single series for its outer container,
359+ # so we pick jammy.
360+ # `lpci` will create an inner jammy container for build:0 and
361+ # an inner focal container for test:0.
362+ self.assertThat(
363+ reports,
364+ MatchesSetwise(
365+ *(
366+ MatchesStructure.byEquality(
367+ creator=repository.owner,
368+ title=title,
369+ git_repository=repository,
370+ commit_sha1=ref.commit_sha1,
371+ ci_build=build,
372+ )
373+ for title, build in [
374+ ("build:0", jammy_build),
375+ ("test:0", jammy_build),
376+ ]
377+ )
378+ ),
379+ )
380+
381 def test_requestBuildsForRefs_no_commits_at_all(self):
382 repository = self.factory.makeGitRepository()
383 ref_paths = ["refs/heads/master"]

Subscribers

People subscribed via source and target branches

to status/vote changes: