Jürgen Gmach has proposed merging ~jugmac00/launchpad:create-lpcraft-jobs-on-push into launchpad:master.
Commit message: WIP: Create lpcraft jobs on push Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/416223 -- Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:create-lpcraft-jobs-on-push into launchpad:master.
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py index 4537b1c..e1abcf8 100644 --- a/lib/lp/code/interfaces/cibuild.py +++ b/lib/lp/code/interfaces/cibuild.py @@ -6,11 +6,16 @@ __all__ = [ "CannotFetchConfiguration", "CannotParseConfiguration", + "CIBuildAlreadyRequested", + "CIBuildDisallowedArchitecture", "ICIBuild", "ICIBuildSet", "MissingConfiguration", ] +import http.client + +from lazr.restful.declarations import error_status from lazr.restful.fields import Reference from zope.schema import ( Bool, @@ -53,6 +58,26 @@ class CannotParseConfiguration(Exception): """Launchpad cannot parse this CI build's .launchpad.yaml.""" +@error_status(http.client.BAD_REQUEST) +class CIBuildDisallowedArchitecture(Exception): + """A build was requested for a disallowed architecture.""" + + def __init__(self, das, pocket): + super().__init__( + "Builds for %s/%s are not allowed." % ( + das.distroseries.getSuite(pocket), das.architecturetag) + ) + + +@error_status(http.client.BAD_REQUEST) +class CIBuildAlreadyRequested(Exception): + """An identical build was requested more than once.""" + + def __init__(self): + super().__init__( + "An identical build for this commit was already requested.") + + class ICIBuildView(IPackageBuildView): """`ICIBuild` attributes that require launchpad.View.""" @@ -133,6 +158,37 @@ class ICIBuildSet(ISpecificBuildFarmJobSource): these Git commit IDs. """ + def requestBuild(git_repository, commit_sha1, distro_arch_series): + """Request a CI build. + + This checks that the architecture is allowed and that there isn't + already a matching pending build. + + :param git_repository: The `IGitRepository` for the new build. + :param commit_sha1: The Git commit ID for the new build. + :param distro_arch_series: The `IDistroArchSeries` that the new + build should run on. + :raises CIBuildDisallowedArchitecture: if builds on + `distro_arch_series` are not allowed. + :raises CIBuildAlreadyRequested: if a matching build was already + requested. + :return: `ICIBuild`. + """ + + def requestBuildsForRefs(git_repository, ref_paths, logger=None): + """Request CI builds for a collection of refs. + + This fetches `.launchpad.yaml` from the repository and parses it to + work out which series/architectures need builds. + + :param git_repository: The `IGitRepository` for which to request + builds. + :param ref_paths: A collection of Git reference paths within + `git_repository`; builds will be requested for the commits that + each of them points to. + :param logger: An optional logger. + """ + def deleteByGitRepository(git_repository): """Delete all CI builds for the given Git repository. diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py index f9c9031..2407aef 100644 --- a/lib/lp/code/model/cibuild.py +++ b/lib/lp/code/model/cibuild.py @@ -9,6 +9,7 @@ __all__ = [ from datetime import timedelta +from lazr.lifecycle.event import ObjectCreatedEvent import pytz from storm.locals import ( Bool, @@ -21,8 +22,11 @@ from storm.locals import ( ) from storm.store import EmptyResultSet from zope.component import getUtility +from zope.event import notify from zope.interface import implementer +from lp.app.errors import NotFoundError +from lp.app.interfaces.launchpad import ILaunchpadCelebrities from lp.buildmaster.enums import ( BuildFarmJobType, BuildQueueStatus, @@ -38,10 +42,14 @@ from lp.code.errors import ( from lp.code.interfaces.cibuild import ( CannotFetchConfiguration, CannotParseConfiguration, + CIBuildAlreadyRequested, + CIBuildDisallowedArchitecture, ICIBuild, ICIBuildSet, MissingConfiguration, ) +from lp.code.interfaces.githosting import IGitHostingClient +from lp.code.model.gitref import GitRef from lp.code.model.lpcraft import load_configuration from lp.registry.interfaces.pocket import PackagePublishingPocket from lp.registry.interfaces.series import SeriesStatus @@ -65,6 +73,53 @@ from lp.services.propertycache import cachedproperty from lp.soyuz.model.distroarchseries import DistroArchSeries +def determine_DASes_to_build(configuration, logger=None): + """Generate distroarchseries to build for this configuration.""" + architectures_by_series = {} + for stage in configuration.pipeline: + for job_name in stage: + if job_name not in configuration.jobs: + if logger is not None: + logger.error("No job definition for %r", job_name) + continue + for job in configuration.jobs[job_name]: + for architecture in job["architectures"]: + architectures_by_series.setdefault( + job["series"], set()).add(architecture) + # XXX cjwatson 2022-01-21: We have to hardcode Ubuntu for now, since + # the .launchpad.yaml format doesn't currently support other + # distributions (although nor does the Launchpad build farm). + distribution = getUtility(ILaunchpadCelebrities).ubuntu + for series_name, architecture_names in architectures_by_series.items(): + try: + series = distribution[series_name] + except NotFoundError: + if logger is not None: + logger.error("Unknown Ubuntu series name %s" % series_name) + continue + architectures = { + das.architecturetag: das + for das in series.buildable_architectures} + for architecture_name in architecture_names: + try: + das = architectures[architecture_name] + except KeyError: + if logger is not None: + logger.error( + "%s is not a buildable architecture name in " + "Ubuntu %s" % (architecture_name, series_name)) + continue + yield das + + +def get_all_commits_for_paths(git_repository, paths): + return [ + ref.commit_sha1 + for ref in GitRef.findByReposAndPaths( + [(git_repository, ref_path) + for ref_path in paths]).values()] + + def parse_configuration(git_repository, blob): try: return load_configuration(blob) @@ -329,6 +384,89 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): store.flush() return cibuild + def findByGitRepository(self, git_repository, commit_sha1s=None): + """See `ICIBuildSet`.""" + clauses = [CIBuild.git_repository == git_repository] + if commit_sha1s is not None: + clauses.append(CIBuild.commit_sha1.is_in(commit_sha1s)) + return IStore(CIBuild).find(CIBuild, *clauses) + + def _isBuildableArchitectureAllowed(self, das): + """Check whether we may build for a buildable `DistroArchSeries`. + + The caller is assumed to have already checked that a suitable chroot + is available (either directly or via + `DistroSeries.buildable_architectures`). + """ + return ( + das.enabled + # We only support builds on virtualized builders at the moment. + and das.processor.supports_virtualized) + + def _isArchitectureAllowed(self, das, pocket, snap_base=None): + return ( + das.getChroot(pocket=pocket) is not None + and self._isBuildableArchitectureAllowed(das)) + + def requestBuild(self, git_repository, commit_sha1, distro_arch_series): + """See `ICIBuildSet`.""" + pocket = PackagePublishingPocket.UPDATES + if not self._isArchitectureAllowed(distro_arch_series, pocket): + raise CIBuildDisallowedArchitecture(distro_arch_series, pocket) + + result = IStore(CIBuild).find( + CIBuild, + CIBuild.git_repository == git_repository, + CIBuild.commit_sha1 == commit_sha1, + CIBuild.distro_arch_series == distro_arch_series) + if not result.is_empty(): + raise CIBuildAlreadyRequested + + build = self.new(git_repository, commit_sha1, distro_arch_series) + build.queueBuild() + notify(ObjectCreatedEvent(build)) + return build + + def requestBuildsForRefs(self, git_repository, ref_paths, logger=None): + """See `ICIBuildSet`.""" + commit_sha1s = get_all_commits_for_paths(git_repository, ref_paths) + # getCommits performs a web request! + commits = getUtility(IGitHostingClient).getCommits( + git_repository.getInternalPath(), commit_sha1s, + # XXX cjwatson 2022-01-19: We should also fetch + # debian/.launchpad.yaml (or perhaps make the path a property of + # the repository) once lpcraft and launchpad-buildd support + # using alternative paths for builds. + filter_paths=[".launchpad.yaml"]) + for commit in commits: + try: + configuration = parse_configuration( + git_repository, commit["blobs"][".launchpad.yaml"]) + except CannotParseConfiguration as e: + if logger is not None: + logger.error(e) + continue + for das in determine_DASes_to_build(configuration): + self._tryToRequestBuild( + git_repository, commit["sha1"], das, logger) + + def _tryToRequestBuild(self, git_repository, commit_sha1, das, logger): + try: + if logger is not None: + logger.info( + "Requesting CI build for %s on %s/%s", + commit_sha1, das.distroseries.name, das.architecturetag, + ) + self.requestBuild(git_repository, commit_sha1, das) + except CIBuildAlreadyRequested: + pass + except Exception as e: + if logger is not None: + logger.error( + "Failed to request CI build for %s on %s/%s: %s", + commit_sha1, das.distroseries.name, das.architecturetag, e + ) + def getByID(self, build_id): """See `ISpecificBuildFarmJobSource`.""" store = IMasterStore(CIBuild) @@ -357,13 +495,6 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin): bfj.id for bfj in build_farm_jobs)) return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData) - def findByGitRepository(self, git_repository, commit_sha1s=None): - """See `ICIBuildSet`.""" - clauses = [CIBuild.git_repository == git_repository] - if commit_sha1s is not None: - clauses.append(CIBuild.commit_sha1.is_in(commit_sha1s)) - return IStore(CIBuild).find(CIBuild, *clauses) - def deleteByGitRepository(self, git_repository): """See `ICIBuildSet`.""" self.findByGitRepository(git_repository).remove() diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py index 133bed2..c8e9721 100644 --- a/lib/lp/code/model/tests/test_cibuild.py +++ b/lib/lp/code/model/tests/test_cibuild.py @@ -7,9 +7,13 @@ from datetime import ( datetime, timedelta, ) +import hashlib from textwrap import dedent +from unittest.mock import Mock +from fixtures import MockPatchObject import pytz +from storm.locals import Store from testtools.matchers import ( Equals, MatchesStructure, @@ -18,9 +22,14 @@ from zope.component import getUtility from zope.security.proxy import removeSecurityProxy from lp.app.enums import InformationType -from lp.buildmaster.enums import BuildStatus +from lp.app.interfaces.launchpad import ILaunchpadCelebrities +from lp.buildmaster.enums import ( + BuildQueueStatus, + BuildStatus, + ) from lp.buildmaster.interfaces.buildqueue import IBuildQueue from lp.buildmaster.interfaces.packagebuild import IPackageBuild +from lp.buildmaster.model.buildqueue import BuildQueue from lp.code.errors import ( GitRepositoryBlobNotFound, GitRepositoryScanFault, @@ -28,12 +37,20 @@ from lp.code.errors import ( from lp.code.interfaces.cibuild import ( CannotFetchConfiguration, CannotParseConfiguration, + CIBuildAlreadyRequested, + CIBuildDisallowedArchitecture, ICIBuild, ICIBuildSet, MissingConfiguration, ) +from lp.code.model.cibuild import ( + determine_DASes_to_build, + get_all_commits_for_paths, + ) +from lp.code.model.lpcraft import load_configuration from lp.code.tests.helpers import GitHostingFixture from lp.registry.interfaces.series import SeriesStatus +from lp.services.log.logger import BufferLogger from lp.services.propertycache import clear_property_cache from lp.testing import ( person_logged_in, @@ -336,6 +353,336 @@ class TestCIBuildSet(TestCaseWithFactory): self.assertContentEqual( builds[2:], ci_build_set.findByGitRepository(repositories[1])) + def test_requestCIBuild(self): + # requestBuild creates a new CIBuild. + repository = self.factory.makeGitRepository() + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + das = self.factory.makeBuildableDistroArchSeries() + + build = getUtility(ICIBuildSet).requestBuild( + repository, commit_sha1, das) + + self.assertTrue(ICIBuild.providedBy(build)) + self.assertThat(build, MatchesStructure.byEquality( + git_repository=repository, + commit_sha1=commit_sha1, + distro_arch_series=das, + status=BuildStatus.NEEDSBUILD, + )) + store = Store.of(build) + store.flush() + build_queue = store.find( + BuildQueue, + BuildQueue._build_farm_job_id == + removeSecurityProxy(build).build_farm_job_id).one() + self.assertProvides(build_queue, IBuildQueue) + self.assertTrue(build_queue.virtualized) + self.assertEqual(BuildQueueStatus.WAITING, build_queue.status) + + def test_requestBuild_score(self): + # CI builds have an initial queue score of 2600. + repository = self.factory.makeGitRepository() + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + das = self.factory.makeBuildableDistroArchSeries() + build = getUtility(ICIBuildSet).requestBuild( + repository, commit_sha1, das) + queue_record = build.buildqueue_record + queue_record.score() + self.assertEqual(2600, queue_record.lastscore) + + def test_requestBuild_rejects_repeats(self): + # requestBuild refuses if an identical build was already requested. + repository = self.factory.makeGitRepository() + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + distro_series = self.factory.makeDistroSeries() + arches = [ + self.factory.makeBuildableDistroArchSeries( + distroseries=distro_series) + for _ in range(2)] + old_build = getUtility(ICIBuildSet).requestBuild( + repository, commit_sha1, arches[0]) + self.assertRaises( + CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild, + repository, commit_sha1, arches[0]) + # We can build for a different distroarchseries. + getUtility(ICIBuildSet).requestBuild( + repository, commit_sha1, arches[1]) + # 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]) + + def test_requestBuild_virtualization(self): + # New builds are virtualized. + repository = self.factory.makeGitRepository() + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + distro_series = self.factory.makeDistroSeries() + for proc_nonvirt in True, False: + das = self.factory.makeBuildableDistroArchSeries( + distroseries=distro_series, supports_virtualized=True, + supports_nonvirtualized=proc_nonvirt) + build = getUtility(ICIBuildSet).requestBuild( + repository, commit_sha1, das) + self.assertTrue(build.virtualized) + + def test_requestBuild_nonvirtualized(self): + # A non-virtualized processor cannot run a CI build. + repository = self.factory.makeGitRepository() + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + distro_series = self.factory.makeDistroSeries() + das = self.factory.makeBuildableDistroArchSeries( + distroseries=distro_series, supports_virtualized=False, + supports_nonvirtualized=True) + self.assertRaises( + CIBuildDisallowedArchitecture, + getUtility(ICIBuildSet).requestBuild, repository, commit_sha1, das) + + def test_requestBuildsForRefs_triggers_builds(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + series = self.factory.makeDistroSeries( + distribution=ubuntu, + name="focal", + ) + self.factory.makeBuildableDistroArchSeries( + distroseries=series, + architecturetag="amd64" + ) + configuration = dedent("""\ + pipeline: + - test + + jobs: + test: + series: focal + architectures: amd64 + run: echo hello world >output + """).encode() + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + [ref] = self.factory.makeGitRefs(repository, ref_paths) + encoded_commit_json = { + "sha1": ref.commit_sha1, + "blobs": {".launchpad.yaml": configuration}, + } + hosting_fixture = self.useFixture( + GitHostingFixture(commits=[encoded_commit_json]) + ) + + getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths) + + self.assertEqual( + [((repository.getInternalPath(), [ref.commit_sha1]), + {"filter_paths": [".launchpad.yaml"]})], + hosting_fixture.getCommits.calls + ) + + build = getUtility(ICIBuildSet).findByGitRepository(repository).one() + + # check that a build was created + 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) + + def test_requestBuildsForRefs_no_commits_at_all(self): + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + hosting_fixture = self.useFixture(GitHostingFixture(commits=[])) + + getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths) + + self.assertEqual( + [((repository.getInternalPath(), []), + {"filter_paths": [".launchpad.yaml"]})], + hosting_fixture.getCommits.calls + ) + + build = getUtility(ICIBuildSet).findByGitRepository(repository).one() + + self.assertIsNone(build) + + def test_requestBuildsForRefs_no_matching_commits(self): + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + [ref] = self.factory.makeGitRefs(repository, ref_paths) + hosting_fixture = self.useFixture( + GitHostingFixture(commits=[]) + ) + + getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths) + + self.assertEqual( + [((repository.getInternalPath(), [ref.commit_sha1]), + {"filter_paths": [".launchpad.yaml"]})], + hosting_fixture.getCommits.calls + ) + + build = getUtility(ICIBuildSet).findByGitRepository(repository).one() + + self.assertIsNone(build) + + def test_requestBuildsForRefs_configuration_parse_error(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + series = self.factory.makeDistroSeries( + distribution=ubuntu, + name="focal", + ) + self.factory.makeBuildableDistroArchSeries( + distroseries=series, + architecturetag="amd64" + ) + configuration = dedent("""\ + no - valid - configuration - file + """).encode() + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + [ref] = self.factory.makeGitRefs(repository, ref_paths) + 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( + repository, ref_paths, logger) + + self.assertEqual( + [((repository.getInternalPath(), [ref.commit_sha1]), + {"filter_paths": [".launchpad.yaml"]})], + hosting_fixture.getCommits.calls + ) + + build = getUtility(ICIBuildSet).findByGitRepository(repository).one() + + self.assertIsNone(build) + + self.assertEqual( + "ERROR Cannot parse .launchpad.yaml from %s: " + "Configuration file does not declare 'pipeline'\n" % ( + repository.unique_name,), + logger.getLogBuffer() + ) + + def test_requestBuildsForRefs_build_already_scheduled(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + series = self.factory.makeDistroSeries( + distribution=ubuntu, + name="focal", + ) + self.factory.makeBuildableDistroArchSeries( + distroseries=series, + architecturetag="amd64" + ) + configuration = dedent("""\ + pipeline: + - test + + jobs: + test: + series: focal + architectures: amd64 + run: echo hello world >output + """).encode() + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + [ref] = self.factory.makeGitRefs(repository, ref_paths) + encoded_commit_json = { + "sha1": ref.commit_sha1, + "blobs": {".launchpad.yaml": configuration}, + } + hosting_fixture = self.useFixture( + GitHostingFixture(commits=[encoded_commit_json]) + ) + build_set = removeSecurityProxy(getUtility(ICIBuildSet)) + mock = Mock(side_effect=CIBuildAlreadyRequested) + self.useFixture(MockPatchObject(build_set, "requestBuild", mock)) + logger = BufferLogger() + + build_set.requestBuildsForRefs(repository, ref_paths, logger) + + self.assertEqual( + [((repository.getInternalPath(), [ref.commit_sha1]), + {"filter_paths": [".launchpad.yaml"]})], + hosting_fixture.getCommits.calls + ) + + build = getUtility(ICIBuildSet).findByGitRepository(repository).one() + + self.assertIsNone(build) + + # XXX jugmac00 2022-03-04 + # for unknown reasons, the logger output starts with a backslash: + # + # actual = '''\ + # INFO Requesting CI build for 972c6d2dc6dd5efdad1377c0d224e03eb8f276f7 on focal/amd64 # noqa: E501 + # ''' + self.assertIn( + "INFO Requesting CI build for %s on focal/amd64" % ref.commit_sha1, + logger.getLogBuffer() + ) + + def test_requestBuildsForRefs_unexpected_exception(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + series = self.factory.makeDistroSeries( + distribution=ubuntu, + name="focal", + ) + self.factory.makeBuildableDistroArchSeries( + distroseries=series, + architecturetag="amd64" + ) + configuration = dedent("""\ + pipeline: + - test + + jobs: + test: + series: focal + architectures: amd64 + run: echo hello world >output + """).encode() + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + [ref] = self.factory.makeGitRefs(repository, ref_paths) + encoded_commit_json = { + "sha1": ref.commit_sha1, + "blobs": {".launchpad.yaml": configuration}, + } + hosting_fixture = self.useFixture( + GitHostingFixture(commits=[encoded_commit_json]) + ) + build_set = removeSecurityProxy(getUtility(ICIBuildSet)) + mock = Mock(side_effect=Exception("some unexpected error")) + self.useFixture(MockPatchObject(build_set, "requestBuild", mock)) + logger = BufferLogger() + + build_set.requestBuildsForRefs(repository, ref_paths, logger) + + self.assertEqual( + [((repository.getInternalPath(), [ref.commit_sha1]), + {"filter_paths": [".launchpad.yaml"]})], + hosting_fixture.getCommits.calls + ) + + build = getUtility(ICIBuildSet).findByGitRepository(repository).one() + + self.assertIsNone(build) + + # last line is an empty string + log_line1, log_line2, _ = logger.getLogBuffer().split("\n") + self.assertEqual( + "INFO Requesting CI build for %s on focal/amd64" % ref.commit_sha1, + log_line1) + self.assertEqual( + "ERROR Failed to request CI build for %s on focal/amd64: " + "some unexpected error" % (ref.commit_sha1,), + log_line2 + ) + def test_deleteByGitRepository(self): repositories = [self.factory.makeGitRepository() for _ in range(2)] builds = [] @@ -350,3 +697,157 @@ class TestCIBuildSet(TestCaseWithFactory): [], ci_build_set.findByGitRepository(repositories[0])) self.assertContentEqual( builds[2:], ci_build_set.findByGitRepository(repositories[1])) + + +class TestCIBuildHelpers(TestCaseWithFactory): + + layer = LaunchpadZopelessLayer + + def test_determine_DASes_to_build(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + distro_serieses = [ + self.factory.makeDistroSeries(ubuntu) for _ in range(2)] + dases = [] + for distro_series in distro_serieses: + for _ in range(2): + dases.append(self.factory.makeBuildableDistroArchSeries( + distroseries=distro_series)) + configuration = load_configuration(dedent("""\ + pipeline: + - [build] + - [test] + jobs: + build: + series: {distro_serieses[1].name} + architectures: + - {dases[2].architecturetag} + - {dases[3].architecturetag} + test: + series: {distro_serieses[1].name} + architectures: + - {dases[2].architecturetag} + """.format(distro_serieses=distro_serieses, dases=dases))) + logger = BufferLogger() + + dases_to_build = list( + determine_DASes_to_build(configuration, logger=logger)) + + self.assertContentEqual(dases[2:], dases_to_build) + self.assertEqual("", logger.getLogBuffer()) + + + def test_determine_DASes_to_build_logs_missing_job_definition(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + distro_series = self.factory.makeDistroSeries(ubuntu) + das = self.factory.makeBuildableDistroArchSeries( + distroseries=distro_series) + configuration = load_configuration(dedent("""\ + pipeline: + - [test] + jobs: + build: + series: {distro_series.name} + architectures: + - {das.architecturetag} + """.format(distro_series=distro_series, das=das))) + logger = BufferLogger() + + dases_to_build = list( + determine_DASes_to_build(configuration, logger=logger)) + + self.assertEqual(0, len(dases_to_build)) + self.assertEqual( + "ERROR No job definition for 'test'\n", logger.getLogBuffer() + ) + + + def test_determine_DASes_to_build_logs_missing_series(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + distro_series = self.factory.makeDistroSeries(ubuntu) + das = self.factory.makeBuildableDistroArchSeries( + distroseries=distro_series) + configuration = load_configuration(dedent("""\ + pipeline: + - [build] + jobs: + build: + series: unknown-series + architectures: + - {das.architecturetag} + """.format(das=das))) + logger = BufferLogger() + + dases_to_build = list( + determine_DASes_to_build(configuration, logger=logger)) + + self.assertEqual(0, len(dases_to_build)) + self.assertEqual( + "ERROR Unknown Ubuntu series name unknown-series\n", + logger.getLogBuffer() + ) + + + def test_determine_DASes_to_build_logs_non_buildable_architecture(self): + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + distro_series = self.factory.makeDistroSeries(ubuntu) + configuration = load_configuration(dedent("""\ + pipeline: + - [build] + jobs: + build: + series: {distro_series.name} + architectures: + - non-buildable-architecture + """.format(distro_series=distro_series))) + logger = BufferLogger() + + dases_to_build = list( + determine_DASes_to_build(configuration, logger=logger)) + + self.assertEqual(0, len(dases_to_build)) + # XXX jugmac00 2022-03-08 + # for unknown reasons, the logger output starts with a backslash: + # actual = '''\ + # ERROR non-buildable-architecture is not a buildable architecture name in Ubuntu distroseries-100005 # noqa: E501 + # ''' + self.assertIn( + "ERROR non-buildable-architecture is not a buildable architecture " + "name in Ubuntu %s" % distro_series.name, + logger.getLogBuffer() + ) + + + +class TestGetAllCommitsForPaths(TestCaseWithFactory): + + layer = LaunchpadZopelessLayer + + def test_no_refs(self): + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + + rv = get_all_commits_for_paths(repository, ref_paths) + + self.assertEqual([], rv) + + def test_one_ref_one_path(self): + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master'] + [ref] = self.factory.makeGitRefs(repository, ref_paths) + + rv = get_all_commits_for_paths(repository, ref_paths) + + self.assertEqual(1, len(rv)) + self.assertEqual(ref.commit_sha1, rv[0]) + + def test_multiple_refs_and_paths(self): + # XXX jugmac00 2022-03-04 + # this test possibly should have multiple commits per path + repository = self.factory.makeGitRepository() + ref_paths = ['refs/heads/master', "refs/heads/dev"] + refs = self.factory.makeGitRefs(repository, ref_paths) + + rv = get_all_commits_for_paths(repository, ref_paths) + + self.assertEqual(2, len(rv)) + self.assertEqual({ref.commit_sha1 for ref in refs}, set(rv)) diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py index 3272b3b..bc17444 100644 --- a/lib/lp/code/model/tests/test_gitrepository.py +++ b/lib/lp/code/model/tests/test_gitrepository.py @@ -11,6 +11,7 @@ import email from functools import partial import hashlib import json +from textwrap import dedent from breezy import urlutils from fixtures import MockPatch @@ -81,6 +82,10 @@ from lp.code.event.git import GitRefsUpdatedEvent from lp.code.interfaces.branchmergeproposal import ( BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES, ) +from lp.code.interfaces.cibuild import ( + ICIBuild, + ICIBuildSet, + ) from lp.code.interfaces.codeimport import ICodeImportSet from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository from lp.code.interfaces.gitjob import ( @@ -161,6 +166,7 @@ from lp.services.identity.interfaces.account import AccountStatus from lp.services.job.interfaces.job import JobStatus from lp.services.job.model.job import Job from lp.services.job.runner import JobRunner +from lp.services.log.logger import BufferLogger from lp.services.macaroons.interfaces import IMacaroonIssuer from lp.services.macaroons.testing import ( find_caveats_by_name, @@ -1461,6 +1467,7 @@ class TestGitRepositoryModifications(TestCaseWithFactory): repository, "date_last_modified", UTC_NOW) def test_create_ref_sets_date_last_modified(self): + self.useFixture(GitHostingFixture()) repository = self.factory.makeGitRepository( date_created=datetime(2015, 6, 1, tzinfo=pytz.UTC)) [ref] = self.factory.makeGitRefs(repository=repository) @@ -1869,6 +1876,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory): repository.refs, repository, ["refs/heads/master"]) def test_update(self): + self.useFixture(GitHostingFixture()) repository = self.factory.makeGitRepository() paths = ("refs/heads/master", "refs/tags/1.0") self.factory.makeGitRefs(repository=repository, paths=paths) @@ -1900,6 +1908,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory): return [UpdatePreviewDiffJob(job) for job in jobs] def test_update_schedules_diff_update(self): + self.useFixture(GitHostingFixture()) repository = self.factory.makeGitRepository() [ref] = self.factory.makeGitRefs(repository=repository) self.assertRefsMatch(repository.refs, repository, [ref.path]) @@ -2210,6 +2219,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory): def test_synchroniseRefs(self): # synchroniseRefs copes with synchronising a repository where some # refs have been created, some deleted, and some changed. + self.useFixture(GitHostingFixture()) repository = self.factory.makeGitRepository() paths = ("refs/heads/master", "refs/heads/foo", "refs/heads/bar") self.factory.makeGitRefs(repository=repository, paths=paths) @@ -2958,6 +2968,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_base_repository_recipe(self): # On ref changes, recipes where this ref is the base become stale. + self.useFixture(GitHostingFixture()) [ref] = self.factory.makeGitRefs() recipe = self.factory.makeSourcePackageRecipe(branches=[ref]) removeSecurityProxy(recipe).is_stale = False @@ -2968,6 +2979,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_base_repository_different_ref_recipe(self): # On ref changes, recipes where a different ref in the same # repository is the base are left alone. + self.useFixture(GitHostingFixture()) ref1, ref2 = self.factory.makeGitRefs( paths=["refs/heads/a", "refs/heads/b"]) recipe = self.factory.makeSourcePackageRecipe(branches=[ref1]) @@ -2979,6 +2991,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_base_repository_default_branch_recipe(self): # On ref changes to the default branch, recipes where this # repository is the base with no explicit revspec become stale. + self.useFixture(GitHostingFixture()) repository = self.factory.makeGitRepository() ref1, ref2 = self.factory.makeGitRefs( repository=repository, paths=["refs/heads/a", "refs/heads/b"]) @@ -2994,6 +3007,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_instruction_repository_recipe(self): # On ref changes, recipes including this ref become stale. + self.useFixture(GitHostingFixture()) [base_ref] = self.factory.makeGitRefs() [ref] = self.factory.makeGitRefs() recipe = self.factory.makeSourcePackageRecipe(branches=[base_ref, ref]) @@ -3005,6 +3019,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_instruction_repository_different_ref_recipe(self): # On ref changes, recipes including a different ref in the same # repository are left alone. + self.useFixture(GitHostingFixture()) [base_ref] = self.factory.makeGitRefs() ref1, ref2 = self.factory.makeGitRefs( paths=["refs/heads/a", "refs/heads/b"]) @@ -3018,6 +3033,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_instruction_repository_default_branch_recipe(self): # On ref changes to the default branch, recipes including this # repository with no explicit revspec become stale. + self.useFixture(GitHostingFixture()) [base_ref] = self.factory.makeGitRefs() repository = self.factory.makeGitRepository() ref1, ref2 = self.factory.makeGitRefs( @@ -3035,6 +3051,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory): def test_unrelated_repository_recipe(self): # On ref changes, unrelated recipes are left alone. + self.useFixture(GitHostingFixture()) [ref] = self.factory.makeGitRefs() recipe = self.factory.makeSourcePackageRecipe( branches=self.factory.makeGitRefs()) @@ -3050,6 +3067,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory): def test_same_repository(self): # On ref changes, snap packages using this ref become stale. + self.useFixture(GitHostingFixture()) [ref] = self.factory.makeGitRefs() snap = self.factory.makeSnap(git_ref=ref) removeSecurityProxy(snap).is_stale = False @@ -3060,6 +3078,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory): def test_same_repository_different_ref(self): # On ref changes, snap packages using a different ref in the same # repository are left alone. + self.useFixture(GitHostingFixture()) ref1, ref2 = self.factory.makeGitRefs( paths=["refs/heads/a", "refs/heads/b"]) snap = self.factory.makeSnap(git_ref=ref1) @@ -3070,6 +3089,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory): def test_different_repository(self): # On ref changes, unrelated snap packages are left alone. + self.useFixture(GitHostingFixture()) [ref] = self.factory.makeGitRefs() snap = self.factory.makeSnap(git_ref=self.factory.makeGitRefs()[0]) removeSecurityProxy(snap).is_stale = False @@ -3079,6 +3099,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory): def test_private_snap(self): # A private snap should be able to be marked stale + self.useFixture(GitHostingFixture()) self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS)) [ref] = self.factory.makeGitRefs() snap = self.factory.makeSnap(git_ref=ref, private=True) @@ -3101,6 +3122,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory): def test_same_repository(self): # On ref changes, charm recipes using this ref become stale. + self.useFixture(GitHostingFixture()) [ref] = self.factory.makeGitRefs() recipe = self.factory.makeCharmRecipe(git_ref=ref) removeSecurityProxy(recipe).is_stale = False @@ -3111,6 +3133,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory): def test_same_repository_different_ref(self): # On ref changes, charm recipes using a different ref in the same # repository are left alone. + self.useFixture(GitHostingFixture()) ref1, ref2 = self.factory.makeGitRefs( paths=["refs/heads/a", "refs/heads/b"]) recipe = self.factory.makeCharmRecipe(git_ref=ref1) @@ -3121,6 +3144,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory): def test_different_repository(self): # On ref changes, unrelated charm recipes are left alone. + self.useFixture(GitHostingFixture()) [ref] = self.factory.makeGitRefs() recipe = self.factory.makeCharmRecipe( git_ref=self.factory.makeGitRefs()[0]) @@ -3321,6 +3345,81 @@ class TestGitRepositoryDetectMerges(TestCaseWithFactory): for event in events[:2]}) +class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory): + + layer = ZopelessDatabaseLayer + + def test_findByGitRepository_with_configuration(self): + # If a changed ref has CI configuration, we request CI builds. + logger = BufferLogger() + [ref] = self.factory.makeGitRefs() + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu + distroseries = self.factory.makeDistroSeries(distribution=ubuntu) + dases = [ + self.factory.makeBuildableDistroArchSeries( + distroseries=distroseries) + for _ in range(2)] + configuration = dedent("""\ + pipeline: [test] + jobs: + test: + series: {series} + architectures: [{architectures}] + """.format( + series=distroseries.name, + architectures=", ".join( + das.architecturetag for das in dases))).encode() + new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + self.useFixture(GitHostingFixture(commits=[ + { + "sha1": new_commit, + "blobs": {".launchpad.yaml": configuration}, + }, + ])) + with dbuser("branchscanner"): + ref.repository.createOrUpdateRefs( + {ref.path: {"sha1": new_commit, "type": GitObjectType.COMMIT}}, + logger=logger) + + results = getUtility(ICIBuildSet).findByGitRepository(ref.repository) + for result in results: + self.assertTrue(ICIBuild.providedBy(result)) + + self.assertThat( + results, + MatchesSetwise(*( + MatchesStructure.byEquality( + git_repository=ref.repository, + commit_sha1=new_commit, + distro_arch_series=das) + for das in dases))) + self.assertContentEqual( + [ + "INFO Requesting CI build for {commit} on " + "{series}/{arch}".format( + commit=new_commit, series=distroseries.name, + arch=das.architecturetag) + for das in dases], + logger.getLogBuffer().splitlines()) + + def test_findByGitRepository_without_configuration(self): + # If a changed ref has no CI configuration, we do not request CI + # builds. + logger = BufferLogger() + [ref] = self.factory.makeGitRefs() + new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() + self.useFixture(GitHostingFixture(commits=[])) + with dbuser("branchscanner"): + ref.repository.createOrUpdateRefs( + {ref.path: {"sha1": new_commit, "type": GitObjectType.COMMIT}}, + logger=logger) + self.assertTrue( + getUtility( + ICIBuildSet).findByGitRepository(ref.repository).is_empty() + ) + self.assertEqual("", logger.getLogBuffer()) + + class TestGitRepositoryGetBlob(TestCaseWithFactory): """Tests for retrieving files from a Git repository.""" diff --git a/lib/lp/code/subscribers/git.py b/lib/lp/code/subscribers/git.py index 30b432e..831ac49 100644 --- a/lib/lp/code/subscribers/git.py +++ b/lib/lp/code/subscribers/git.py @@ -1,8 +1,13 @@ -# Copyright 2015-2016 Canonical Ltd. This software is licensed under the +# Copyright 2015-2022 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Event subscribers for Git repositories.""" +from zope.component import getUtility + +from lp.code.interfaces.cibuild import ICIBuildSet + + def refs_updated(repository, event): """Some references in a Git repository have been updated.""" repository.updateMergeCommitIDs(event.paths) @@ -11,3 +16,5 @@ def refs_updated(repository, event): repository.markSnapsStale(event.paths) repository.markCharmRecipesStale(event.paths) repository.detectMerges(event.paths, logger=event.logger) + getUtility(ICIBuildSet).requestBuildsForRefs( + repository, event.paths, logger=event.logger) diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py index 041cb89..83ad621 100644 --- a/lib/lp/testing/factory.py +++ b/lib/lp/testing/factory.py @@ -97,11 +97,15 @@ from lp.bugs.interfaces.cve import ( ) from lp.bugs.model.bug import FileBugData from lp.buildmaster.enums import ( + BuildBaseImageType, BuilderResetProtocol, BuildStatus, ) from lp.buildmaster.interfaces.builder import IBuilderSet -from lp.buildmaster.interfaces.processor import IProcessorSet +from lp.buildmaster.interfaces.processor import ( + IProcessorSet, + ProcessorNotFound, + ) from lp.charms.interfaces.charmbase import ICharmBaseSet from lp.charms.interfaces.charmrecipe import ICharmRecipeSet from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet @@ -2909,6 +2913,34 @@ class BareLaunchpadObjectFactory(ObjectFactory): return distroseries.newArch( architecturetag, processor, official, owner, enabled) + def makeBuildableDistroArchSeries(self, architecturetag=None, + processor=None, + supports_virtualized=True, + supports_nonvirtualized=True, **kwargs): + if architecturetag is None: + architecturetag = self.getUniqueUnicode("arch") + if processor is None: + try: + processor = getUtility(IProcessorSet).getByName( + architecturetag) + except ProcessorNotFound: + processor = self.makeProcessor( + name=architecturetag, + supports_virtualized=supports_virtualized, + supports_nonvirtualized=supports_nonvirtualized) + das = self.makeDistroArchSeries( + architecturetag=architecturetag, processor=processor, **kwargs) + # Add both a chroot and a LXD image to test that + # getAllowedArchitectures doesn't get confused by multiple + # PocketChroot rows for a single DistroArchSeries. + fake_chroot = self.makeLibraryFileAlias( + filename="fake_chroot.tar.gz", db_only=True) + das.addOrUpdateChroot(fake_chroot) + fake_lxd = self.makeLibraryFileAlias( + filename="fake_lxd.tar.gz", db_only=True) + das.addOrUpdateChroot(fake_lxd, image_type=BuildBaseImageType.LXD) + return das + def makeComponent(self, name=None): """Make a new `IComponent`.""" if name is None:
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp