Merge ~jugmac00/launchpad:create-lpcraft-jobs-on-push into launchpad:master

Proposed by Jürgen Gmach
Status: Merged
Approved by: Jürgen Gmach
Approved revision: 4195ec844f18ceef354f7309cc1abcbc2d0f2dff
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~jugmac00/launchpad:create-lpcraft-jobs-on-push
Merge into: launchpad:master
Diff against target: 1163 lines (+821/-10)
6 files modified
lib/lp/code/interfaces/cibuild.py (+56/-0)
lib/lp/code/model/cibuild.py (+138/-7)
lib/lp/code/model/tests/test_cibuild.py (+487/-1)
lib/lp/code/model/tests/test_gitrepository.py (+99/-0)
lib/lp/code/subscribers/git.py (+8/-1)
lib/lp/testing/factory.py (+33/-1)
Reviewer Review Type Date Requested Status
Colin Watson Approve
Review via email: mp+416223@code.launchpad.net

Commit message

WIP: Create lpcraft jobs on push

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

@Colin: While the biggest part of the code is still the one you prepared, maybe you can have a look at what I added and give me feedback on my suggestions.

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

Thanks for the feedback! I have applied most suggestions, and now I am ready to implement the missing test cases.

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

I will rebase/squash the many commits once the MP is approved - I kept them for now as I may need to revert something.

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

Ready for the next review!

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks a lot for the detailed review!

Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
2index 4537b1c..e1abcf8 100644
3--- a/lib/lp/code/interfaces/cibuild.py
4+++ b/lib/lp/code/interfaces/cibuild.py
5@@ -6,11 +6,16 @@
6 __all__ = [
7 "CannotFetchConfiguration",
8 "CannotParseConfiguration",
9+ "CIBuildAlreadyRequested",
10+ "CIBuildDisallowedArchitecture",
11 "ICIBuild",
12 "ICIBuildSet",
13 "MissingConfiguration",
14 ]
15
16+import http.client
17+
18+from lazr.restful.declarations import error_status
19 from lazr.restful.fields import Reference
20 from zope.schema import (
21 Bool,
22@@ -53,6 +58,26 @@ class CannotParseConfiguration(Exception):
23 """Launchpad cannot parse this CI build's .launchpad.yaml."""
24
25
26+@error_status(http.client.BAD_REQUEST)
27+class CIBuildDisallowedArchitecture(Exception):
28+ """A build was requested for a disallowed architecture."""
29+
30+ def __init__(self, das, pocket):
31+ super().__init__(
32+ "Builds for %s/%s are not allowed." % (
33+ das.distroseries.getSuite(pocket), das.architecturetag)
34+ )
35+
36+
37+@error_status(http.client.BAD_REQUEST)
38+class CIBuildAlreadyRequested(Exception):
39+ """An identical build was requested more than once."""
40+
41+ def __init__(self):
42+ super().__init__(
43+ "An identical build for this commit was already requested.")
44+
45+
46 class ICIBuildView(IPackageBuildView):
47 """`ICIBuild` attributes that require launchpad.View."""
48
49@@ -133,6 +158,37 @@ class ICIBuildSet(ISpecificBuildFarmJobSource):
50 these Git commit IDs.
51 """
52
53+ def requestBuild(git_repository, commit_sha1, distro_arch_series):
54+ """Request a CI build.
55+
56+ This checks that the architecture is allowed and that there isn't
57+ already a matching pending build.
58+
59+ :param git_repository: The `IGitRepository` for the new build.
60+ :param commit_sha1: The Git commit ID for the new build.
61+ :param distro_arch_series: The `IDistroArchSeries` that the new
62+ build should run on.
63+ :raises CIBuildDisallowedArchitecture: if builds on
64+ `distro_arch_series` are not allowed.
65+ :raises CIBuildAlreadyRequested: if a matching build was already
66+ requested.
67+ :return: `ICIBuild`.
68+ """
69+
70+ def requestBuildsForRefs(git_repository, ref_paths, logger=None):
71+ """Request CI builds for a collection of refs.
72+
73+ This fetches `.launchpad.yaml` from the repository and parses it to
74+ work out which series/architectures need builds.
75+
76+ :param git_repository: The `IGitRepository` for which to request
77+ builds.
78+ :param ref_paths: A collection of Git reference paths within
79+ `git_repository`; builds will be requested for the commits that
80+ each of them points to.
81+ :param logger: An optional logger.
82+ """
83+
84 def deleteByGitRepository(git_repository):
85 """Delete all CI builds for the given Git repository.
86
87diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
88index f9c9031..bdc570c 100644
89--- a/lib/lp/code/model/cibuild.py
90+++ b/lib/lp/code/model/cibuild.py
91@@ -9,6 +9,7 @@ __all__ = [
92
93 from datetime import timedelta
94
95+from lazr.lifecycle.event import ObjectCreatedEvent
96 import pytz
97 from storm.locals import (
98 Bool,
99@@ -21,8 +22,11 @@ from storm.locals import (
100 )
101 from storm.store import EmptyResultSet
102 from zope.component import getUtility
103+from zope.event import notify
104 from zope.interface import implementer
105
106+from lp.app.errors import NotFoundError
107+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
108 from lp.buildmaster.enums import (
109 BuildFarmJobType,
110 BuildQueueStatus,
111@@ -38,10 +42,14 @@ from lp.code.errors import (
112 from lp.code.interfaces.cibuild import (
113 CannotFetchConfiguration,
114 CannotParseConfiguration,
115+ CIBuildAlreadyRequested,
116+ CIBuildDisallowedArchitecture,
117 ICIBuild,
118 ICIBuildSet,
119 MissingConfiguration,
120 )
121+from lp.code.interfaces.githosting import IGitHostingClient
122+from lp.code.model.gitref import GitRef
123 from lp.code.model.lpcraft import load_configuration
124 from lp.registry.interfaces.pocket import PackagePublishingPocket
125 from lp.registry.interfaces.series import SeriesStatus
126@@ -65,6 +73,53 @@ from lp.services.propertycache import cachedproperty
127 from lp.soyuz.model.distroarchseries import DistroArchSeries
128
129
130+def determine_DASes_to_build(configuration, logger=None):
131+ """Generate distroarchseries to build for this configuration."""
132+ architectures_by_series = {}
133+ for stage in configuration.pipeline:
134+ for job_name in stage:
135+ if job_name not in configuration.jobs:
136+ if logger is not None:
137+ logger.error("No job definition for %r", job_name)
138+ continue
139+ for job in configuration.jobs[job_name]:
140+ for architecture in job["architectures"]:
141+ architectures_by_series.setdefault(
142+ job["series"], set()).add(architecture)
143+ # XXX cjwatson 2022-01-21: We have to hardcode Ubuntu for now, since
144+ # the .launchpad.yaml format doesn't currently support other
145+ # distributions (although nor does the Launchpad build farm).
146+ distribution = getUtility(ILaunchpadCelebrities).ubuntu
147+ for series_name, architecture_names in architectures_by_series.items():
148+ try:
149+ series = distribution[series_name]
150+ except NotFoundError:
151+ if logger is not None:
152+ logger.error("Unknown Ubuntu series name %s" % series_name)
153+ continue
154+ architectures = {
155+ das.architecturetag: das
156+ for das in series.buildable_architectures}
157+ for architecture_name in architecture_names:
158+ try:
159+ das = architectures[architecture_name]
160+ except KeyError:
161+ if logger is not None:
162+ logger.error(
163+ "%s is not a buildable architecture name in "
164+ "Ubuntu %s" % (architecture_name, series_name))
165+ continue
166+ yield das
167+
168+
169+def get_all_commits_for_paths(git_repository, paths):
170+ return [
171+ ref.commit_sha1
172+ for ref in GitRef.findByReposAndPaths(
173+ [(git_repository, ref_path)
174+ for ref_path in paths]).values()]
175+
176+
177 def parse_configuration(git_repository, blob):
178 try:
179 return load_configuration(blob)
180@@ -329,6 +384,89 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
181 store.flush()
182 return cibuild
183
184+ def findByGitRepository(self, git_repository, commit_sha1s=None):
185+ """See `ICIBuildSet`."""
186+ clauses = [CIBuild.git_repository == git_repository]
187+ if commit_sha1s is not None:
188+ clauses.append(CIBuild.commit_sha1.is_in(commit_sha1s))
189+ return IStore(CIBuild).find(CIBuild, *clauses)
190+
191+ def _isBuildableArchitectureAllowed(self, das):
192+ """Check whether we may build for a buildable `DistroArchSeries`.
193+
194+ The caller is assumed to have already checked that a suitable chroot
195+ is available (either directly or via
196+ `DistroSeries.buildable_architectures`).
197+ """
198+ return (
199+ das.enabled
200+ # We only support builds on virtualized builders at the moment.
201+ and das.processor.supports_virtualized)
202+
203+ def _isArchitectureAllowed(self, das, pocket, snap_base=None):
204+ return (
205+ das.getChroot(pocket=pocket) is not None
206+ and self._isBuildableArchitectureAllowed(das))
207+
208+ def requestBuild(self, git_repository, commit_sha1, distro_arch_series):
209+ """See `ICIBuildSet`."""
210+ pocket = PackagePublishingPocket.UPDATES
211+ if not self._isArchitectureAllowed(distro_arch_series, pocket):
212+ raise CIBuildDisallowedArchitecture(distro_arch_series, pocket)
213+
214+ result = IStore(CIBuild).find(
215+ CIBuild,
216+ CIBuild.git_repository == git_repository,
217+ CIBuild.commit_sha1 == commit_sha1,
218+ CIBuild.distro_arch_series == distro_arch_series)
219+ if not result.is_empty():
220+ raise CIBuildAlreadyRequested
221+
222+ build = self.new(git_repository, commit_sha1, distro_arch_series)
223+ build.queueBuild()
224+ notify(ObjectCreatedEvent(build))
225+ return build
226+
227+ def _tryToRequestBuild(self, git_repository, commit_sha1, das, logger):
228+ try:
229+ if logger is not None:
230+ logger.info(
231+ "Requesting CI build for %s on %s/%s",
232+ commit_sha1, das.distroseries.name, das.architecturetag,
233+ )
234+ self.requestBuild(git_repository, commit_sha1, das)
235+ except CIBuildAlreadyRequested:
236+ pass
237+ except Exception as e:
238+ if logger is not None:
239+ logger.error(
240+ "Failed to request CI build for %s on %s/%s: %s",
241+ commit_sha1, das.distroseries.name, das.architecturetag, e
242+ )
243+
244+ def requestBuildsForRefs(self, git_repository, ref_paths, logger=None):
245+ """See `ICIBuildSet`."""
246+ commit_sha1s = get_all_commits_for_paths(git_repository, ref_paths)
247+ # getCommits performs a web request!
248+ commits = getUtility(IGitHostingClient).getCommits(
249+ git_repository.getInternalPath(), commit_sha1s,
250+ # XXX cjwatson 2022-01-19: We should also fetch
251+ # debian/.launchpad.yaml (or perhaps make the path a property of
252+ # the repository) once lpcraft and launchpad-buildd support
253+ # using alternative paths for builds.
254+ filter_paths=[".launchpad.yaml"])
255+ for commit in commits:
256+ try:
257+ configuration = parse_configuration(
258+ git_repository, commit["blobs"][".launchpad.yaml"])
259+ except CannotParseConfiguration as e:
260+ if logger is not None:
261+ logger.error(e)
262+ continue
263+ for das in determine_DASes_to_build(configuration):
264+ self._tryToRequestBuild(
265+ git_repository, commit["sha1"], das, logger)
266+
267 def getByID(self, build_id):
268 """See `ISpecificBuildFarmJobSource`."""
269 store = IMasterStore(CIBuild)
270@@ -357,13 +495,6 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
271 bfj.id for bfj in build_farm_jobs))
272 return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
273
274- def findByGitRepository(self, git_repository, commit_sha1s=None):
275- """See `ICIBuildSet`."""
276- clauses = [CIBuild.git_repository == git_repository]
277- if commit_sha1s is not None:
278- clauses.append(CIBuild.commit_sha1.is_in(commit_sha1s))
279- return IStore(CIBuild).find(CIBuild, *clauses)
280-
281 def deleteByGitRepository(self, git_repository):
282 """See `ICIBuildSet`."""
283 self.findByGitRepository(git_repository).remove()
284diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
285index 133bed2..bd2c5e9 100644
286--- a/lib/lp/code/model/tests/test_cibuild.py
287+++ b/lib/lp/code/model/tests/test_cibuild.py
288@@ -7,9 +7,13 @@ from datetime import (
289 datetime,
290 timedelta,
291 )
292+import hashlib
293 from textwrap import dedent
294+from unittest.mock import Mock
295
296+from fixtures import MockPatchObject
297 import pytz
298+from storm.locals import Store
299 from testtools.matchers import (
300 Equals,
301 MatchesStructure,
302@@ -18,9 +22,14 @@ from zope.component import getUtility
303 from zope.security.proxy import removeSecurityProxy
304
305 from lp.app.enums import InformationType
306-from lp.buildmaster.enums import BuildStatus
307+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
308+from lp.buildmaster.enums import (
309+ BuildQueueStatus,
310+ BuildStatus,
311+ )
312 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
313 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
314+from lp.buildmaster.model.buildqueue import BuildQueue
315 from lp.code.errors import (
316 GitRepositoryBlobNotFound,
317 GitRepositoryScanFault,
318@@ -28,12 +37,20 @@ from lp.code.errors import (
319 from lp.code.interfaces.cibuild import (
320 CannotFetchConfiguration,
321 CannotParseConfiguration,
322+ CIBuildAlreadyRequested,
323+ CIBuildDisallowedArchitecture,
324 ICIBuild,
325 ICIBuildSet,
326 MissingConfiguration,
327 )
328+from lp.code.model.cibuild import (
329+ determine_DASes_to_build,
330+ get_all_commits_for_paths,
331+ )
332+from lp.code.model.lpcraft import load_configuration
333 from lp.code.tests.helpers import GitHostingFixture
334 from lp.registry.interfaces.series import SeriesStatus
335+from lp.services.log.logger import BufferLogger
336 from lp.services.propertycache import clear_property_cache
337 from lp.testing import (
338 person_logged_in,
339@@ -44,6 +61,39 @@ from lp.testing.layers import LaunchpadZopelessLayer
340 from lp.testing.matchers import HasQueryCount
341
342
343+class TestGetAllCommitsForPaths(TestCaseWithFactory):
344+
345+ layer = LaunchpadZopelessLayer
346+
347+ def test_no_refs(self):
348+ repository = self.factory.makeGitRepository()
349+ ref_paths = ['refs/heads/master']
350+
351+ rv = get_all_commits_for_paths(repository, ref_paths)
352+
353+ self.assertEqual([], rv)
354+
355+ def test_one_ref_one_path(self):
356+ repository = self.factory.makeGitRepository()
357+ ref_paths = ['refs/heads/master']
358+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
359+
360+ rv = get_all_commits_for_paths(repository, ref_paths)
361+
362+ self.assertEqual(1, len(rv))
363+ self.assertEqual(ref.commit_sha1, rv[0])
364+
365+ def test_multiple_refs_and_paths(self):
366+ repository = self.factory.makeGitRepository()
367+ ref_paths = ['refs/heads/master', "refs/heads/dev"]
368+ refs = self.factory.makeGitRefs(repository, ref_paths)
369+
370+ rv = get_all_commits_for_paths(repository, ref_paths)
371+
372+ self.assertEqual(2, len(rv))
373+ self.assertEqual({ref.commit_sha1 for ref in refs}, set(rv))
374+
375+
376 class TestCIBuild(TestCaseWithFactory):
377
378 layer = LaunchpadZopelessLayer
379@@ -336,6 +386,329 @@ class TestCIBuildSet(TestCaseWithFactory):
380 self.assertContentEqual(
381 builds[2:], ci_build_set.findByGitRepository(repositories[1]))
382
383+ def test_requestCIBuild(self):
384+ # requestBuild creates a new CIBuild.
385+ repository = self.factory.makeGitRepository()
386+ commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
387+ das = self.factory.makeBuildableDistroArchSeries()
388+
389+ build = getUtility(ICIBuildSet).requestBuild(
390+ repository, commit_sha1, das)
391+
392+ self.assertTrue(ICIBuild.providedBy(build))
393+ self.assertThat(build, MatchesStructure.byEquality(
394+ git_repository=repository,
395+ commit_sha1=commit_sha1,
396+ distro_arch_series=das,
397+ status=BuildStatus.NEEDSBUILD,
398+ ))
399+ store = Store.of(build)
400+ store.flush()
401+ build_queue = store.find(
402+ BuildQueue,
403+ BuildQueue._build_farm_job_id ==
404+ removeSecurityProxy(build).build_farm_job_id).one()
405+ self.assertProvides(build_queue, IBuildQueue)
406+ self.assertTrue(build_queue.virtualized)
407+ self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
408+
409+ def test_requestBuild_score(self):
410+ # CI builds have an initial queue score of 2600.
411+ repository = self.factory.makeGitRepository()
412+ commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
413+ das = self.factory.makeBuildableDistroArchSeries()
414+ build = getUtility(ICIBuildSet).requestBuild(
415+ repository, commit_sha1, das)
416+ queue_record = build.buildqueue_record
417+ queue_record.score()
418+ self.assertEqual(2600, queue_record.lastscore)
419+
420+ def test_requestBuild_rejects_repeats(self):
421+ # requestBuild refuses if an identical build was already requested.
422+ repository = self.factory.makeGitRepository()
423+ commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
424+ distro_series = self.factory.makeDistroSeries()
425+ arches = [
426+ self.factory.makeBuildableDistroArchSeries(
427+ distroseries=distro_series)
428+ for _ in range(2)]
429+ old_build = getUtility(ICIBuildSet).requestBuild(
430+ repository, commit_sha1, arches[0])
431+ self.assertRaises(
432+ CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild,
433+ repository, commit_sha1, arches[0])
434+ # We can build for a different distroarchseries.
435+ getUtility(ICIBuildSet).requestBuild(
436+ repository, commit_sha1, arches[1])
437+ # Changing the status of the old build does not allow a new build.
438+ old_build.updateStatus(BuildStatus.BUILDING)
439+ old_build.updateStatus(BuildStatus.FULLYBUILT)
440+ self.assertRaises(
441+ CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild,
442+ repository, commit_sha1, arches[0])
443+
444+ def test_requestBuild_virtualization(self):
445+ # New builds are virtualized.
446+ repository = self.factory.makeGitRepository()
447+ commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
448+ distro_series = self.factory.makeDistroSeries()
449+ for proc_nonvirt in True, False:
450+ das = self.factory.makeBuildableDistroArchSeries(
451+ distroseries=distro_series, supports_virtualized=True,
452+ supports_nonvirtualized=proc_nonvirt)
453+ build = getUtility(ICIBuildSet).requestBuild(
454+ repository, commit_sha1, das)
455+ self.assertTrue(build.virtualized)
456+
457+ def test_requestBuild_nonvirtualized(self):
458+ # A non-virtualized processor cannot run a CI build.
459+ repository = self.factory.makeGitRepository()
460+ commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
461+ distro_series = self.factory.makeDistroSeries()
462+ das = self.factory.makeBuildableDistroArchSeries(
463+ distroseries=distro_series, supports_virtualized=False,
464+ supports_nonvirtualized=True)
465+ self.assertRaises(
466+ CIBuildDisallowedArchitecture,
467+ getUtility(ICIBuildSet).requestBuild, repository, commit_sha1, das)
468+
469+ def test_requestBuildsForRefs_triggers_builds(self):
470+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
471+ series = self.factory.makeDistroSeries(
472+ distribution=ubuntu,
473+ name="focal",
474+ )
475+ self.factory.makeBuildableDistroArchSeries(
476+ distroseries=series,
477+ architecturetag="amd64"
478+ )
479+ configuration = dedent("""\
480+ pipeline:
481+ - test
482+
483+ jobs:
484+ test:
485+ series: focal
486+ architectures: amd64
487+ run: echo hello world >output
488+ """).encode()
489+ repository = self.factory.makeGitRepository()
490+ ref_paths = ['refs/heads/master']
491+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
492+ encoded_commit_json = {
493+ "sha1": ref.commit_sha1,
494+ "blobs": {".launchpad.yaml": configuration},
495+ }
496+ hosting_fixture = self.useFixture(
497+ GitHostingFixture(commits=[encoded_commit_json])
498+ )
499+
500+ getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
501+
502+ self.assertEqual(
503+ [((repository.getInternalPath(), [ref.commit_sha1]),
504+ {"filter_paths": [".launchpad.yaml"]})],
505+ hosting_fixture.getCommits.calls
506+ )
507+
508+ build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
509+
510+ # check that a build was created
511+ self.assertEqual(ref.commit_sha1, build.commit_sha1)
512+ self.assertEqual("focal", build.distro_arch_series.distroseries.name)
513+ self.assertEqual("amd64", build.distro_arch_series.architecturetag)
514+
515+ def test_requestBuildsForRefs_no_commits_at_all(self):
516+ repository = self.factory.makeGitRepository()
517+ ref_paths = ['refs/heads/master']
518+ hosting_fixture = self.useFixture(GitHostingFixture(commits=[]))
519+
520+ getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
521+
522+ self.assertEqual(
523+ [((repository.getInternalPath(), []),
524+ {"filter_paths": [".launchpad.yaml"]})],
525+ hosting_fixture.getCommits.calls
526+ )
527+
528+ self.assertTrue(
529+ getUtility(ICIBuildSet).findByGitRepository(repository).is_empty()
530+ )
531+
532+ def test_requestBuildsForRefs_no_matching_commits(self):
533+ repository = self.factory.makeGitRepository()
534+ ref_paths = ['refs/heads/master']
535+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
536+ hosting_fixture = self.useFixture(
537+ GitHostingFixture(commits=[])
538+ )
539+
540+ getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
541+
542+ self.assertEqual(
543+ [((repository.getInternalPath(), [ref.commit_sha1]),
544+ {"filter_paths": [".launchpad.yaml"]})],
545+ hosting_fixture.getCommits.calls
546+ )
547+
548+ self.assertTrue(
549+ getUtility(ICIBuildSet).findByGitRepository(repository).is_empty()
550+ )
551+
552+ def test_requestBuildsForRefs_configuration_parse_error(self):
553+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
554+ series = self.factory.makeDistroSeries(
555+ distribution=ubuntu,
556+ name="focal",
557+ )
558+ self.factory.makeBuildableDistroArchSeries(
559+ distroseries=series,
560+ architecturetag="amd64"
561+ )
562+ configuration = dedent("""\
563+ no - valid - configuration - file
564+ """).encode()
565+ repository = self.factory.makeGitRepository()
566+ ref_paths = ['refs/heads/master']
567+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
568+ encoded_commit_json = {
569+ "sha1": ref.commit_sha1,
570+ "blobs": {".launchpad.yaml": configuration},
571+ }
572+ hosting_fixture = self.useFixture(
573+ GitHostingFixture(commits=[encoded_commit_json])
574+ )
575+ logger = BufferLogger()
576+
577+ getUtility(ICIBuildSet).requestBuildsForRefs(
578+ repository, ref_paths, logger)
579+
580+ self.assertEqual(
581+ [((repository.getInternalPath(), [ref.commit_sha1]),
582+ {"filter_paths": [".launchpad.yaml"]})],
583+ hosting_fixture.getCommits.calls
584+ )
585+
586+ self.assertTrue(
587+ getUtility(ICIBuildSet).findByGitRepository(repository).is_empty()
588+ )
589+
590+ self.assertEqual(
591+ "ERROR Cannot parse .launchpad.yaml from %s: "
592+ "Configuration file does not declare 'pipeline'\n" % (
593+ repository.unique_name,),
594+ logger.getLogBuffer()
595+ )
596+
597+ def test_requestBuildsForRefs_build_already_scheduled(self):
598+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
599+ series = self.factory.makeDistroSeries(
600+ distribution=ubuntu,
601+ name="focal",
602+ )
603+ self.factory.makeBuildableDistroArchSeries(
604+ distroseries=series,
605+ architecturetag="amd64"
606+ )
607+ configuration = dedent("""\
608+ pipeline:
609+ - test
610+
611+ jobs:
612+ test:
613+ series: focal
614+ architectures: amd64
615+ run: echo hello world >output
616+ """).encode()
617+ repository = self.factory.makeGitRepository()
618+ ref_paths = ['refs/heads/master']
619+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
620+ encoded_commit_json = {
621+ "sha1": ref.commit_sha1,
622+ "blobs": {".launchpad.yaml": configuration},
623+ }
624+ hosting_fixture = self.useFixture(
625+ GitHostingFixture(commits=[encoded_commit_json])
626+ )
627+ build_set = removeSecurityProxy(getUtility(ICIBuildSet))
628+ mock = Mock(side_effect=CIBuildAlreadyRequested)
629+ self.useFixture(MockPatchObject(build_set, "requestBuild", mock))
630+ logger = BufferLogger()
631+
632+ build_set.requestBuildsForRefs(repository, ref_paths, logger)
633+
634+ self.assertEqual(
635+ [((repository.getInternalPath(), [ref.commit_sha1]),
636+ {"filter_paths": [".launchpad.yaml"]})],
637+ hosting_fixture.getCommits.calls
638+ )
639+
640+ self.assertTrue(
641+ getUtility(ICIBuildSet).findByGitRepository(repository).is_empty()
642+ )
643+ self.assertEqual(
644+ "INFO Requesting CI build "
645+ "for %s on focal/amd64\n" % ref.commit_sha1,
646+ logger.getLogBuffer()
647+ )
648+
649+ def test_requestBuildsForRefs_unexpected_exception(self):
650+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
651+ series = self.factory.makeDistroSeries(
652+ distribution=ubuntu,
653+ name="focal",
654+ )
655+ self.factory.makeBuildableDistroArchSeries(
656+ distroseries=series,
657+ architecturetag="amd64"
658+ )
659+ configuration = dedent("""\
660+ pipeline:
661+ - test
662+
663+ jobs:
664+ test:
665+ series: focal
666+ architectures: amd64
667+ run: echo hello world >output
668+ """).encode()
669+ repository = self.factory.makeGitRepository()
670+ ref_paths = ['refs/heads/master']
671+ [ref] = self.factory.makeGitRefs(repository, ref_paths)
672+ encoded_commit_json = {
673+ "sha1": ref.commit_sha1,
674+ "blobs": {".launchpad.yaml": configuration},
675+ }
676+ hosting_fixture = self.useFixture(
677+ GitHostingFixture(commits=[encoded_commit_json])
678+ )
679+ build_set = removeSecurityProxy(getUtility(ICIBuildSet))
680+ mock = Mock(side_effect=Exception("some unexpected error"))
681+ self.useFixture(MockPatchObject(build_set, "requestBuild", mock))
682+ logger = BufferLogger()
683+
684+ build_set.requestBuildsForRefs(repository, ref_paths, logger)
685+
686+ self.assertEqual(
687+ [((repository.getInternalPath(), [ref.commit_sha1]),
688+ {"filter_paths": [".launchpad.yaml"]})],
689+ hosting_fixture.getCommits.calls
690+ )
691+
692+ self.assertTrue(
693+ getUtility(ICIBuildSet).findByGitRepository(repository).is_empty()
694+ )
695+
696+ log_line1, log_line2 = logger.getLogBuffer().splitlines()
697+ self.assertEqual(
698+ "INFO Requesting CI build for %s on focal/amd64" % ref.commit_sha1,
699+ log_line1)
700+ self.assertEqual(
701+ "ERROR Failed to request CI build for %s on focal/amd64: "
702+ "some unexpected error" % (ref.commit_sha1,),
703+ log_line2
704+ )
705+
706 def test_deleteByGitRepository(self):
707 repositories = [self.factory.makeGitRepository() for _ in range(2)]
708 builds = []
709@@ -350,3 +723,116 @@ class TestCIBuildSet(TestCaseWithFactory):
710 [], ci_build_set.findByGitRepository(repositories[0]))
711 self.assertContentEqual(
712 builds[2:], ci_build_set.findByGitRepository(repositories[1]))
713+
714+
715+class TestDetermineDASesToBuild(TestCaseWithFactory):
716+
717+ layer = LaunchpadZopelessLayer
718+
719+ def test_returns_expected_DASes(self):
720+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
721+ distro_serieses = [
722+ self.factory.makeDistroSeries(ubuntu) for _ in range(2)]
723+ dases = []
724+ for distro_series in distro_serieses:
725+ for _ in range(2):
726+ dases.append(self.factory.makeBuildableDistroArchSeries(
727+ distroseries=distro_series))
728+ configuration = load_configuration(dedent("""\
729+ pipeline:
730+ - [build]
731+ - [test]
732+ jobs:
733+ build:
734+ series: {distro_serieses[1].name}
735+ architectures:
736+ - {dases[2].architecturetag}
737+ - {dases[3].architecturetag}
738+ test:
739+ series: {distro_serieses[1].name}
740+ architectures:
741+ - {dases[2].architecturetag}
742+ """.format(distro_serieses=distro_serieses, dases=dases)))
743+ logger = BufferLogger()
744+
745+ dases_to_build = list(
746+ determine_DASes_to_build(configuration, logger=logger))
747+
748+ self.assertContentEqual(dases[2:], dases_to_build)
749+ self.assertEqual("", logger.getLogBuffer())
750+
751+
752+ def test_logs_missing_job_definition(self):
753+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
754+ distro_series = self.factory.makeDistroSeries(ubuntu)
755+ das = self.factory.makeBuildableDistroArchSeries(
756+ distroseries=distro_series)
757+ configuration = load_configuration(dedent("""\
758+ pipeline:
759+ - [test]
760+ jobs:
761+ build:
762+ series: {distro_series.name}
763+ architectures:
764+ - {das.architecturetag}
765+ """.format(distro_series=distro_series, das=das)))
766+ logger = BufferLogger()
767+
768+ dases_to_build = list(
769+ determine_DASes_to_build(configuration, logger=logger))
770+
771+ self.assertEqual(0, len(dases_to_build))
772+ self.assertEqual(
773+ "ERROR No job definition for 'test'\n", logger.getLogBuffer()
774+ )
775+
776+
777+ def test_logs_missing_series(self):
778+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
779+ distro_series = self.factory.makeDistroSeries(ubuntu)
780+ das = self.factory.makeBuildableDistroArchSeries(
781+ distroseries=distro_series)
782+ configuration = load_configuration(dedent("""\
783+ pipeline:
784+ - [build]
785+ jobs:
786+ build:
787+ series: unknown-series
788+ architectures:
789+ - {das.architecturetag}
790+ """.format(das=das)))
791+ logger = BufferLogger()
792+
793+ dases_to_build = list(
794+ determine_DASes_to_build(configuration, logger=logger))
795+
796+ self.assertEqual(0, len(dases_to_build))
797+ self.assertEqual(
798+ "ERROR Unknown Ubuntu series name unknown-series\n",
799+ logger.getLogBuffer()
800+ )
801+
802+
803+ def test_logs_non_buildable_architecture(self):
804+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
805+ distro_series = self.factory.makeDistroSeries(ubuntu)
806+ configuration = load_configuration(dedent("""\
807+ pipeline:
808+ - [build]
809+ jobs:
810+ build:
811+ series: {distro_series.name}
812+ architectures:
813+ - non-buildable-architecture
814+ """.format(distro_series=distro_series)))
815+ logger = BufferLogger()
816+
817+ dases_to_build = list(
818+ determine_DASes_to_build(configuration, logger=logger))
819+
820+ self.assertEqual(0, len(dases_to_build))
821+ self.assertEqual(
822+ "ERROR non-buildable-architecture is not a buildable architecture "
823+ "name in Ubuntu %s\n" % distro_series.name,
824+ logger.getLogBuffer()
825+ )
826diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
827index 46781f2..fd1f9eb 100644
828--- a/lib/lp/code/model/tests/test_gitrepository.py
829+++ b/lib/lp/code/model/tests/test_gitrepository.py
830@@ -11,6 +11,7 @@ import email
831 from functools import partial
832 import hashlib
833 import json
834+from textwrap import dedent
835
836 from breezy import urlutils
837 from fixtures import MockPatch
838@@ -85,6 +86,10 @@ from lp.code.event.git import GitRefsUpdatedEvent
839 from lp.code.interfaces.branchmergeproposal import (
840 BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES,
841 )
842+from lp.code.interfaces.cibuild import (
843+ ICIBuild,
844+ ICIBuildSet,
845+ )
846 from lp.code.interfaces.codeimport import ICodeImportSet
847 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
848 from lp.code.interfaces.gitjob import (
849@@ -169,6 +174,7 @@ from lp.services.identity.interfaces.account import AccountStatus
850 from lp.services.job.interfaces.job import JobStatus
851 from lp.services.job.model.job import Job
852 from lp.services.job.runner import JobRunner
853+from lp.services.log.logger import BufferLogger
854 from lp.services.macaroons.interfaces import IMacaroonIssuer
855 from lp.services.macaroons.testing import (
856 find_caveats_by_name,
857@@ -1469,6 +1475,7 @@ class TestGitRepositoryModifications(TestCaseWithFactory):
858 repository, "date_last_modified", UTC_NOW)
859
860 def test_create_ref_sets_date_last_modified(self):
861+ self.useFixture(GitHostingFixture())
862 repository = self.factory.makeGitRepository(
863 date_created=datetime(2015, 6, 1, tzinfo=pytz.UTC))
864 [ref] = self.factory.makeGitRefs(repository=repository)
865@@ -1882,6 +1889,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
866 repository.refs, repository, ["refs/heads/master"])
867
868 def test_update(self):
869+ self.useFixture(GitHostingFixture())
870 repository = self.factory.makeGitRepository()
871 paths = ("refs/heads/master", "refs/tags/1.0")
872 self.factory.makeGitRefs(repository=repository, paths=paths)
873@@ -1913,6 +1921,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
874 return [UpdatePreviewDiffJob(job) for job in jobs]
875
876 def test_update_schedules_diff_update(self):
877+ self.useFixture(GitHostingFixture())
878 repository = self.factory.makeGitRepository()
879 [ref] = self.factory.makeGitRefs(repository=repository)
880 self.assertRefsMatch(repository.refs, repository, [ref.path])
881@@ -2223,6 +2232,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
882 def test_synchroniseRefs(self):
883 # synchroniseRefs copes with synchronising a repository where some
884 # refs have been created, some deleted, and some changed.
885+ self.useFixture(GitHostingFixture())
886 repository = self.factory.makeGitRepository()
887 paths = ("refs/heads/master", "refs/heads/foo", "refs/heads/bar")
888 self.factory.makeGitRefs(repository=repository, paths=paths)
889@@ -2992,6 +3002,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
890
891 def test_base_repository_recipe(self):
892 # On ref changes, recipes where this ref is the base become stale.
893+ self.useFixture(GitHostingFixture())
894 [ref] = self.factory.makeGitRefs()
895 recipe = self.factory.makeSourcePackageRecipe(branches=[ref])
896 removeSecurityProxy(recipe).is_stale = False
897@@ -3002,6 +3013,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
898 def test_base_repository_different_ref_recipe(self):
899 # On ref changes, recipes where a different ref in the same
900 # repository is the base are left alone.
901+ self.useFixture(GitHostingFixture())
902 ref1, ref2 = self.factory.makeGitRefs(
903 paths=["refs/heads/a", "refs/heads/b"])
904 recipe = self.factory.makeSourcePackageRecipe(branches=[ref1])
905@@ -3013,6 +3025,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
906 def test_base_repository_default_branch_recipe(self):
907 # On ref changes to the default branch, recipes where this
908 # repository is the base with no explicit revspec become stale.
909+ self.useFixture(GitHostingFixture())
910 repository = self.factory.makeGitRepository()
911 ref1, ref2 = self.factory.makeGitRefs(
912 repository=repository, paths=["refs/heads/a", "refs/heads/b"])
913@@ -3028,6 +3041,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
914
915 def test_instruction_repository_recipe(self):
916 # On ref changes, recipes including this ref become stale.
917+ self.useFixture(GitHostingFixture())
918 [base_ref] = self.factory.makeGitRefs()
919 [ref] = self.factory.makeGitRefs()
920 recipe = self.factory.makeSourcePackageRecipe(branches=[base_ref, ref])
921@@ -3039,6 +3053,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
922 def test_instruction_repository_different_ref_recipe(self):
923 # On ref changes, recipes including a different ref in the same
924 # repository are left alone.
925+ self.useFixture(GitHostingFixture())
926 [base_ref] = self.factory.makeGitRefs()
927 ref1, ref2 = self.factory.makeGitRefs(
928 paths=["refs/heads/a", "refs/heads/b"])
929@@ -3052,6 +3067,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
930 def test_instruction_repository_default_branch_recipe(self):
931 # On ref changes to the default branch, recipes including this
932 # repository with no explicit revspec become stale.
933+ self.useFixture(GitHostingFixture())
934 [base_ref] = self.factory.makeGitRefs()
935 repository = self.factory.makeGitRepository()
936 ref1, ref2 = self.factory.makeGitRefs(
937@@ -3069,6 +3085,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
938
939 def test_unrelated_repository_recipe(self):
940 # On ref changes, unrelated recipes are left alone.
941+ self.useFixture(GitHostingFixture())
942 [ref] = self.factory.makeGitRefs()
943 recipe = self.factory.makeSourcePackageRecipe(
944 branches=self.factory.makeGitRefs())
945@@ -3084,6 +3101,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
946
947 def test_same_repository(self):
948 # On ref changes, snap packages using this ref become stale.
949+ self.useFixture(GitHostingFixture())
950 [ref] = self.factory.makeGitRefs()
951 snap = self.factory.makeSnap(git_ref=ref)
952 removeSecurityProxy(snap).is_stale = False
953@@ -3094,6 +3112,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
954 def test_same_repository_different_ref(self):
955 # On ref changes, snap packages using a different ref in the same
956 # repository are left alone.
957+ self.useFixture(GitHostingFixture())
958 ref1, ref2 = self.factory.makeGitRefs(
959 paths=["refs/heads/a", "refs/heads/b"])
960 snap = self.factory.makeSnap(git_ref=ref1)
961@@ -3104,6 +3123,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
962
963 def test_different_repository(self):
964 # On ref changes, unrelated snap packages are left alone.
965+ self.useFixture(GitHostingFixture())
966 [ref] = self.factory.makeGitRefs()
967 snap = self.factory.makeSnap(git_ref=self.factory.makeGitRefs()[0])
968 removeSecurityProxy(snap).is_stale = False
969@@ -3113,6 +3133,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
970
971 def test_private_snap(self):
972 # A private snap should be able to be marked stale
973+ self.useFixture(GitHostingFixture())
974 self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
975 [ref] = self.factory.makeGitRefs()
976 snap = self.factory.makeSnap(git_ref=ref, private=True)
977@@ -3135,6 +3156,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory):
978
979 def test_same_repository(self):
980 # On ref changes, charm recipes using this ref become stale.
981+ self.useFixture(GitHostingFixture())
982 [ref] = self.factory.makeGitRefs()
983 recipe = self.factory.makeCharmRecipe(git_ref=ref)
984 removeSecurityProxy(recipe).is_stale = False
985@@ -3145,6 +3167,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory):
986 def test_same_repository_different_ref(self):
987 # On ref changes, charm recipes using a different ref in the same
988 # repository are left alone.
989+ self.useFixture(GitHostingFixture())
990 ref1, ref2 = self.factory.makeGitRefs(
991 paths=["refs/heads/a", "refs/heads/b"])
992 recipe = self.factory.makeCharmRecipe(git_ref=ref1)
993@@ -3155,6 +3178,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory):
994
995 def test_different_repository(self):
996 # On ref changes, unrelated charm recipes are left alone.
997+ self.useFixture(GitHostingFixture())
998 [ref] = self.factory.makeGitRefs()
999 recipe = self.factory.makeCharmRecipe(
1000 git_ref=self.factory.makeGitRefs()[0])
1001@@ -3355,6 +3379,81 @@ class TestGitRepositoryDetectMerges(TestCaseWithFactory):
1002 for event in events[:2]})
1003
1004
1005+class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory):
1006+
1007+ layer = ZopelessDatabaseLayer
1008+
1009+ def test_findByGitRepository_with_configuration(self):
1010+ # If a changed ref has CI configuration, we request CI builds.
1011+ logger = BufferLogger()
1012+ [ref] = self.factory.makeGitRefs()
1013+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
1014+ distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
1015+ dases = [
1016+ self.factory.makeBuildableDistroArchSeries(
1017+ distroseries=distroseries)
1018+ for _ in range(2)]
1019+ configuration = dedent("""\
1020+ pipeline: [test]
1021+ jobs:
1022+ test:
1023+ series: {series}
1024+ architectures: [{architectures}]
1025+ """.format(
1026+ series=distroseries.name,
1027+ architectures=", ".join(
1028+ das.architecturetag for das in dases))).encode()
1029+ new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
1030+ self.useFixture(GitHostingFixture(commits=[
1031+ {
1032+ "sha1": new_commit,
1033+ "blobs": {".launchpad.yaml": configuration},
1034+ },
1035+ ]))
1036+ with dbuser("branchscanner"):
1037+ ref.repository.createOrUpdateRefs(
1038+ {ref.path: {"sha1": new_commit, "type": GitObjectType.COMMIT}},
1039+ logger=logger)
1040+
1041+ results = getUtility(ICIBuildSet).findByGitRepository(ref.repository)
1042+ for result in results:
1043+ self.assertTrue(ICIBuild.providedBy(result))
1044+
1045+ self.assertThat(
1046+ results,
1047+ MatchesSetwise(*(
1048+ MatchesStructure.byEquality(
1049+ git_repository=ref.repository,
1050+ commit_sha1=new_commit,
1051+ distro_arch_series=das)
1052+ for das in dases)))
1053+ self.assertContentEqual(
1054+ [
1055+ "INFO Requesting CI build for {commit} on "
1056+ "{series}/{arch}".format(
1057+ commit=new_commit, series=distroseries.name,
1058+ arch=das.architecturetag)
1059+ for das in dases],
1060+ logger.getLogBuffer().splitlines())
1061+
1062+ def test_findByGitRepository_without_configuration(self):
1063+ # If a changed ref has no CI configuration, we do not request CI
1064+ # builds.
1065+ logger = BufferLogger()
1066+ [ref] = self.factory.makeGitRefs()
1067+ new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
1068+ self.useFixture(GitHostingFixture(commits=[]))
1069+ with dbuser("branchscanner"):
1070+ ref.repository.createOrUpdateRefs(
1071+ {ref.path: {"sha1": new_commit, "type": GitObjectType.COMMIT}},
1072+ logger=logger)
1073+ self.assertTrue(
1074+ getUtility(
1075+ ICIBuildSet).findByGitRepository(ref.repository).is_empty()
1076+ )
1077+ self.assertEqual("", logger.getLogBuffer())
1078+
1079+
1080 class TestGitRepositoryGetBlob(TestCaseWithFactory):
1081 """Tests for retrieving files from a Git repository."""
1082
1083diff --git a/lib/lp/code/subscribers/git.py b/lib/lp/code/subscribers/git.py
1084index 30b432e..831ac49 100644
1085--- a/lib/lp/code/subscribers/git.py
1086+++ b/lib/lp/code/subscribers/git.py
1087@@ -1,8 +1,13 @@
1088-# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
1089+# Copyright 2015-2022 Canonical Ltd. This software is licensed under the
1090 # GNU Affero General Public License version 3 (see the file LICENSE).
1091
1092 """Event subscribers for Git repositories."""
1093
1094+from zope.component import getUtility
1095+
1096+from lp.code.interfaces.cibuild import ICIBuildSet
1097+
1098+
1099 def refs_updated(repository, event):
1100 """Some references in a Git repository have been updated."""
1101 repository.updateMergeCommitIDs(event.paths)
1102@@ -11,3 +16,5 @@ def refs_updated(repository, event):
1103 repository.markSnapsStale(event.paths)
1104 repository.markCharmRecipesStale(event.paths)
1105 repository.detectMerges(event.paths, logger=event.logger)
1106+ getUtility(ICIBuildSet).requestBuildsForRefs(
1107+ repository, event.paths, logger=event.logger)
1108diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
1109index 8e0ec69..e145133 100644
1110--- a/lib/lp/testing/factory.py
1111+++ b/lib/lp/testing/factory.py
1112@@ -97,11 +97,15 @@ from lp.bugs.interfaces.cve import (
1113 )
1114 from lp.bugs.model.bug import FileBugData
1115 from lp.buildmaster.enums import (
1116+ BuildBaseImageType,
1117 BuilderResetProtocol,
1118 BuildStatus,
1119 )
1120 from lp.buildmaster.interfaces.builder import IBuilderSet
1121-from lp.buildmaster.interfaces.processor import IProcessorSet
1122+from lp.buildmaster.interfaces.processor import (
1123+ IProcessorSet,
1124+ ProcessorNotFound,
1125+ )
1126 from lp.charms.interfaces.charmbase import ICharmBaseSet
1127 from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
1128 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
1129@@ -2932,6 +2936,34 @@ class BareLaunchpadObjectFactory(ObjectFactory):
1130 return distroseries.newArch(
1131 architecturetag, processor, official, owner, enabled)
1132
1133+ def makeBuildableDistroArchSeries(self, architecturetag=None,
1134+ processor=None,
1135+ supports_virtualized=True,
1136+ supports_nonvirtualized=True, **kwargs):
1137+ if architecturetag is None:
1138+ architecturetag = self.getUniqueUnicode("arch")
1139+ if processor is None:
1140+ try:
1141+ processor = getUtility(IProcessorSet).getByName(
1142+ architecturetag)
1143+ except ProcessorNotFound:
1144+ processor = self.makeProcessor(
1145+ name=architecturetag,
1146+ supports_virtualized=supports_virtualized,
1147+ supports_nonvirtualized=supports_nonvirtualized)
1148+ das = self.makeDistroArchSeries(
1149+ architecturetag=architecturetag, processor=processor, **kwargs)
1150+ # Add both a chroot and a LXD image to test that
1151+ # getAllowedArchitectures doesn't get confused by multiple
1152+ # PocketChroot rows for a single DistroArchSeries.
1153+ fake_chroot = self.makeLibraryFileAlias(
1154+ filename="fake_chroot.tar.gz", db_only=True)
1155+ das.addOrUpdateChroot(fake_chroot)
1156+ fake_lxd = self.makeLibraryFileAlias(
1157+ filename="fake_lxd.tar.gz", db_only=True)
1158+ das.addOrUpdateChroot(fake_lxd, image_type=BuildBaseImageType.LXD)
1159+ return das
1160+
1161 def makeComponent(self, name=None):
1162 """Make a new `IComponent`."""
1163 if name is None:

Subscribers

People subscribed via source and target branches

to status/vote changes: