Merge ~pelpsi/launchpad:duplicate_ci_jobs into launchpad:master
- Git
- lp:~pelpsi/launchpad
- duplicate_ci_jobs
- Merge into master
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) |
Related bugs: |
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
Description of the change
Colin Watson (cjwatson) wrote : | # |
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:/
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
architect
build-snaps: [snapcraft]
run: snapcraft
test-snap:
matrix:
- series: bionic
- series: focal
- series: jammy
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.
Simone Pelosi (pelpsi) wrote : | # |
> 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:/
> 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.
> list of build f...
Colin Watson (cjwatson) : | # |
Simone Pelosi (pelpsi) wrote : | # |
Updated and commits squashed!
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.
Colin Watson (cjwatson) : | # |
Simone Pelosi (pelpsi) wrote : | # |
Squashed into one commit
Preview Diff
1 | diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py |
2 | index 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 | |
113 | diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py |
114 | index 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"] |
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.