launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #28029
[Merge] ~cjwatson/launchpad:ci-build-behaviour into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:ci-build-behaviour into launchpad:master with ~cjwatson/launchpad:ci-build as a prerequisite.
Commit message:
Add a build behaviour for CI builds
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/414769
There's no way to request these builds, and no uploader handling to process the output of them either; and I haven't implemented incremental fetching of the logs/output of individual jobs during the build. Still, this makes a bit more progress on putting the pieces together.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build-behaviour into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index a6f6ca4..2d20f6c 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -1018,6 +1018,7 @@ public.charmrecipe = SELECT
public.charmrecipebuild = SELECT, UPDATE
public.charmrecipebuildjob = SELECT, INSERT
public.charmrecipejob = SELECT
+public.cibuild = SELECT, UPDATE
public.component = SELECT
public.componentselection = SELECT
public.distribution = SELECT, UPDATE
diff --git a/lib/lp/buildmaster/model/buildfarmjobbehaviour.py b/lib/lp/buildmaster/model/buildfarmjobbehaviour.py
index a7537d3..1792d78 100644
--- a/lib/lp/buildmaster/model/buildfarmjobbehaviour.py
+++ b/lib/lp/buildmaster/model/buildfarmjobbehaviour.py
@@ -341,7 +341,8 @@ class BuildFarmJobBehaviourBase:
transaction.commit()
@defer.inlineCallbacks
- def _downloadFiles(self, filemap, upload_path, logger):
+ def _downloadFiles(self, worker_status, upload_path, logger):
+ filemap = worker_status["filemap"]
filenames_to_download = []
for filename, sha1 in filemap.items():
logger.info("Grabbing file: %s (%s)" % (
@@ -365,7 +366,6 @@ class BuildFarmJobBehaviourBase:
uploader.
"""
build = self.build
- filemap = worker_status['filemap']
# If this is a binary package build, discard it if its source is
# no longer published.
@@ -393,7 +393,7 @@ class BuildFarmJobBehaviourBase:
grab_dir, str(build.archive.id), build.distribution.name)
os.makedirs(upload_path)
- yield self._downloadFiles(filemap, upload_path, logger)
+ yield self._downloadFiles(worker_status, upload_path, logger)
transaction.commit()
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 6bbd418..dc3d63e 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1303,6 +1303,13 @@
<allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
</securedutility>
+ <!-- CIBuildBehaviour -->
+ <adapter
+ for="lp.code.interfaces.cibuild.ICIBuild"
+ provides="lp.buildmaster.interfaces.buildfarmjobbehaviour.IBuildFarmJobBehaviour"
+ factory="lp.code.model.cibuildbehaviour.CIBuildBehaviour"
+ permission="zope.Public" />
+
<webservice:register module="lp.code.interfaces.webservice" />
<adapter
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index 96781f1..4537b1c 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -4,8 +4,11 @@
"""Interfaces for CI builds."""
__all__ = [
+ "CannotFetchConfiguration",
+ "CannotParseConfiguration",
"ICIBuild",
"ICIBuildSet",
+ "MissingConfiguration",
]
from lazr.restful.fields import Reference
@@ -31,6 +34,25 @@ from lp.services.database.constants import DEFAULT
from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+class MissingConfiguration(Exception):
+ """The repository for this CI build does not have a .launchpad.yaml."""
+
+ def __init__(self, name):
+ super().__init__("Cannot find .launchpad.yaml in %s" % name)
+
+
+class CannotFetchConfiguration(Exception):
+ """Launchpad cannot fetch this CI build's .launchpad.yaml."""
+
+ def __init__(self, message, unsupported_remote=False):
+ super().__init__(message)
+ self.unsupported_remote = unsupported_remote
+
+
+class CannotParseConfiguration(Exception):
+ """Launchpad cannot parse this CI build's .launchpad.yaml."""
+
+
class ICIBuildView(IPackageBuildView):
"""`ICIBuild` attributes that require launchpad.View."""
@@ -68,6 +90,21 @@ class ICIBuildView(IPackageBuildView):
"The date when the build completed or is estimated to complete."),
readonly=True)
+ def getConfiguration(logger=None):
+ """Fetch a CI build's .launchpad.yaml from code hosting, if possible.
+
+ :param logger: An optional logger.
+
+ :return: The build's parsed .launchpad.yaml.
+ :raises MissingConfiguration: if this package has no
+ .launchpad.yaml.
+ :raises CannotFetchConfiguration: if it was not possible to fetch
+ .launchpad.yaml from the code hosting backend for some other
+ reason.
+ :raises CannotParseConfiguration: if the fetched .launchpad.yaml
+ cannot be parsed.
+ """
+
class ICIBuildEdit(IBuildFarmJobEdit):
"""`ICIBuild` methods that require launchpad.Edit."""
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index 12f2633..f9c9031 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -31,10 +31,18 @@ from lp.buildmaster.enums import (
from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.code.errors import (
+ GitRepositoryBlobNotFound,
+ GitRepositoryScanFault,
+ )
from lp.code.interfaces.cibuild import (
+ CannotFetchConfiguration,
+ CannotParseConfiguration,
ICIBuild,
ICIBuildSet,
+ MissingConfiguration,
)
+from lp.code.model.lpcraft import load_configuration
from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.registry.interfaces.series import SeriesStatus
from lp.registry.model.distribution import Distribution
@@ -57,6 +65,16 @@ from lp.services.propertycache import cachedproperty
from lp.soyuz.model.distroarchseries import DistroArchSeries
+def parse_configuration(git_repository, blob):
+ try:
+ return load_configuration(blob)
+ except Exception as e:
+ # Don't bother logging parsing errors from user-supplied YAML.
+ raise CannotParseConfiguration(
+ "Cannot parse .launchpad.yaml from %s: %s" %
+ (git_repository.unique_name, e))
+
+
@implementer(ICIBuild)
class CIBuild(PackageBuildMixin, StormBase):
"""See `ICIBuild`."""
@@ -258,6 +276,33 @@ class CIBuild(PackageBuildMixin, StormBase):
return self.eta
return self.date_finished
+ def getConfiguration(self, logger=None):
+ """See `ICIBuild`."""
+ try:
+ paths = (
+ ".launchpad.yaml",
+ )
+ for path in paths:
+ try:
+ blob = self.git_repository.getBlob(
+ path, rev=self.commit_sha1)
+ break
+ except GitRepositoryBlobNotFound:
+ pass
+ else:
+ if logger is not None:
+ logger.exception(
+ "Cannot find .launchpad.yaml in %s" %
+ self.git_repository.unique_name)
+ raise MissingConfiguration(self.git_repository.unique_name)
+ except GitRepositoryScanFault as e:
+ msg = "Failed to get .launchpad.yaml from %s"
+ if logger is not None:
+ logger.exception(msg, self.git_repository.unique_name)
+ raise CannotFetchConfiguration(
+ "%s: %s" % (msg % self.git_repository.unique_name, e))
+ return parse_configuration(self.git_repository, blob)
+
def verifySuccessfulUpload(self):
"""See `IPackageBuild`."""
# We have no interesting checks to perform here.
diff --git a/lib/lp/code/model/cibuildbehaviour.py b/lib/lp/code/model/cibuildbehaviour.py
new file mode 100644
index 0000000..77f04af
--- /dev/null
+++ b/lib/lp/code/model/cibuildbehaviour.py
@@ -0,0 +1,113 @@
+# Copyright 2022 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""An `IBuildFarmJobBehaviour` for `CIBuild`."""
+
+__all__ = [
+ "CIBuildBehaviour",
+ ]
+
+import json
+import os
+
+from twisted.internet import defer
+from zope.component import adapter
+from zope.interface import implementer
+
+from lp.buildmaster.builderproxy import BuilderProxyMixin
+from lp.buildmaster.enums import BuildBaseImageType
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+ IBuildFarmJobBehaviour,
+ )
+from lp.buildmaster.model.buildfarmjobbehaviour import (
+ BuildFarmJobBehaviourBase,
+ )
+from lp.code.interfaces.cibuild import ICIBuild
+from lp.soyuz.adapters.archivedependencies import (
+ get_sources_list_for_building,
+ )
+
+
+@adapter(ICIBuild)
+@implementer(IBuildFarmJobBehaviour)
+class CIBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
+ """Dispatches `CIBuild` jobs to builders."""
+
+ builder_type = "ci"
+ image_types = [BuildBaseImageType.LXD, BuildBaseImageType.CHROOT]
+
+ ALLOWED_STATUS_NOTIFICATIONS = []
+
+ def getLogFileName(self):
+ return "buildlog_ci_%s_%s_%s.txt" % (
+ self.build.git_repository.name, self.build.commit_sha1,
+ self.build.status.name)
+
+ def verifyBuildRequest(self, logger):
+ """Assert some pre-build checks.
+
+ The build request is checked:
+ * Virtualized builds can't build on a non-virtual builder
+ * We have a base image
+ """
+ build = self.build
+ if build.virtualized and not self._builder.virtualized:
+ raise AssertionError(
+ "Attempt to build virtual item on a non-virtual builder.")
+
+ chroot = build.distro_arch_series.getChroot(pocket=build.pocket)
+ if chroot is None:
+ raise CannotBuild(
+ "Missing chroot for %s" % build.distro_arch_series.displayname)
+
+ @defer.inlineCallbacks
+ def extraBuildArgs(self, logger=None):
+ """Return extra builder arguments for this build."""
+ build = self.build
+ try:
+ configuration = self.build.getConfiguration(logger=logger)
+ except Exception as e:
+ raise CannotBuild(str(e))
+ stages = []
+ if not configuration.pipeline:
+ raise CannotBuild(
+ "No jobs defined for %s:%s" %
+ (build.git_repository.unique_name, build.commit_sha1))
+ for stage in configuration.pipeline:
+ jobs = []
+ for job_name in stage:
+ if job_name not in configuration.jobs:
+ raise CannotBuild(
+ "Job '%s' in pipeline for %s:%s but not in jobs" %
+ (job_name,
+ build.git_repository.unique_name, build.commit_sha1))
+ for i in range(len(configuration.jobs[job_name])):
+ jobs.append((job_name, i))
+ stages.append(jobs)
+
+ args = yield super().extraBuildArgs(logger=logger)
+ yield self.addProxyArgs(args)
+ args["archives"], args["trusted_keys"] = (
+ yield get_sources_list_for_building(
+ self, build.distro_arch_series, None, logger=logger))
+ args["jobs"] = stages
+ args["git_repository"] = build.git_repository.git_https_url
+ args["commit_sha1"] = build.commit_sha1
+ args["private"] = build.is_private
+ return args
+
+ def verifySuccessfulBuild(self):
+ """See `IBuildFarmJobBehaviour`."""
+ # We have no interesting checks to perform here.
+
+ @defer.inlineCallbacks
+ def _downloadFiles(self, worker_status, upload_path, logger):
+ # In addition to downloading everything from the filemap, we need to
+ # save the "jobs" field in order to reliably map files to individual
+ # CI jobs.
+ if "jobs" in worker_status:
+ jobs_path = os.path.join(upload_path, "jobs.json")
+ with open(jobs_path, "w") as jobs_file:
+ json.dump(worker_status["jobs"], jobs_file)
+ yield super()._downloadFiles(worker_status, upload_path, logger)
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index 784a241..133bed2 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,
)
+from textwrap import dedent
import pytz
-from testtools.matchers import Equals
+from testtools.matchers import (
+ Equals,
+ MatchesStructure,
+ )
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
@@ -17,10 +21,18 @@ from lp.app.enums import InformationType
from lp.buildmaster.enums import BuildStatus
from lp.buildmaster.interfaces.buildqueue import IBuildQueue
from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.code.errors import (
+ GitRepositoryBlobNotFound,
+ GitRepositoryScanFault,
+ )
from lp.code.interfaces.cibuild import (
+ CannotFetchConfiguration,
+ CannotParseConfiguration,
ICIBuild,
ICIBuildSet,
+ MissingConfiguration,
)
+from lp.code.tests.helpers import GitHostingFixture
from lp.registry.interfaces.series import SeriesStatus
from lp.services.propertycache import clear_property_cache
from lp.testing import (
@@ -222,6 +234,55 @@ class TestCIBuild(TestCaseWithFactory):
clear_property_cache(build)
self.assertFalse(build.estimate)
+ def test_getConfiguration(self):
+ build = self.factory.makeCIBuild()
+ das = build.distro_arch_series
+ self.useFixture(GitHostingFixture(blob=dedent("""\
+ pipeline: [test]
+ jobs:
+ test:
+ series: {}
+ architectures: [{}]
+ """.format(das.distroseries.name, das.architecturetag)).encode()))
+ self.assertThat(
+ build.getConfiguration(),
+ MatchesStructure.byEquality(
+ pipeline=[["test"]],
+ jobs={
+ "test": [{
+ "series": das.distroseries.name,
+ "architectures": [das.architecturetag],
+ }]}))
+
+ def test_getConfiguration_not_found(self):
+ build = self.factory.makeCIBuild()
+ self.useFixture(GitHostingFixture()).getBlob.failure = (
+ GitRepositoryBlobNotFound(
+ build.git_repository.getInternalPath(), ".launchpad.yaml",
+ rev=build.commit_sha1))
+ self.assertRaisesWithContent(
+ MissingConfiguration,
+ "Cannot find .launchpad.yaml in %s" % (
+ build.git_repository.unique_name),
+ build.getConfiguration)
+
+ def test_getConfiguration_fetch_error(self):
+ build = self.factory.makeCIBuild()
+ self.useFixture(GitHostingFixture()).getBlob.failure = (
+ GitRepositoryScanFault("Boom"))
+ self.assertRaisesWithContent(
+ CannotFetchConfiguration,
+ "Failed to get .launchpad.yaml from %s: Boom" % (
+ build.git_repository.unique_name),
+ build.getConfiguration)
+
+ def test_getConfiguration_invalid_data(self):
+ build = self.factory.makeCIBuild()
+ hosting_fixture = self.useFixture(GitHostingFixture())
+ for invalid_result in (None, 123, b"", b"[][]", b"#name:test", b"]"):
+ hosting_fixture.getBlob.result = invalid_result
+ self.assertRaises(CannotParseConfiguration, build.getConfiguration)
+
class TestCIBuildSet(TestCaseWithFactory):
diff --git a/lib/lp/code/model/tests/test_cibuildbehaviour.py b/lib/lp/code/model/tests/test_cibuildbehaviour.py
new file mode 100644
index 0000000..808144a
--- /dev/null
+++ b/lib/lp/code/model/tests/test_cibuildbehaviour.py
@@ -0,0 +1,474 @@
+# Copyright 2022 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test CI build behaviour."""
+
+import base64
+from datetime import datetime
+import json
+import os.path
+import re
+from textwrap import dedent
+import time
+from urllib.parse import urlsplit
+import uuid
+
+from fixtures import MockPatch
+from testtools import ExpectedException
+from testtools.matchers import (
+ ContainsDict,
+ Equals,
+ Is,
+ IsInstance,
+ MatchesDict,
+ MatchesListwise,
+ StartsWith,
+ )
+from testtools.twistedsupport import (
+ AsynchronousDeferredRunTestForBrokenTwisted,
+ )
+from twisted.internet import defer
+from zope.component import getUtility
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.archivepublisher.interfaces.archivegpgsigningkey import (
+ IArchiveGPGSigningKey,
+ )
+from lp.buildmaster.enums import (
+ BuildBaseImageType,
+ BuildStatus,
+ )
+from lp.buildmaster.interactor import shut_down_default_process_pool
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+ IBuildFarmJobBehaviour,
+ )
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.buildmaster.tests.builderproxy import (
+ InProcessProxyAuthAPIFixture,
+ ProxyURLMatcher,
+ RevocationEndpointMatcher,
+ )
+from lp.buildmaster.tests.mock_workers import (
+ MockBuilder,
+ OkWorker,
+ WorkerTestHelpers,
+ )
+from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
+ TestGetUploadMethodsMixin,
+ TestHandleStatusMixin,
+ TestVerifySuccessfulBuildMixin,
+ )
+from lp.code.errors import GitRepositoryBlobNotFound
+from lp.code.model.cibuildbehaviour import CIBuildBehaviour
+from lp.code.tests.helpers import GitHostingFixture
+from lp.services.config import config
+from lp.services.log.logger import (
+ BufferLogger,
+ DevNullLogger,
+ )
+from lp.services.statsd.tests import StatsMixin
+from lp.services.timeout import (
+ get_default_timeout_function,
+ set_default_timeout_function,
+ )
+from lp.services.webapp import canonical_url
+from lp.soyuz.adapters.archivedependencies import (
+ get_sources_list_for_building,
+ )
+from lp.soyuz.enums import PackagePublishingStatus
+from lp.soyuz.tests.soyuz import Base64KeyMatches
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.gpgkeys import gpgkeysdir
+from lp.testing.keyserver import InProcessKeyServerFixture
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestCIBuildBehaviourBase(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ def makeJob(self, **kwargs):
+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+ distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
+ processor = getUtility(IProcessorSet).getByName("386")
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, architecturetag="i386",
+ processor=processor)
+
+ # Taken from test_archivedependencies.py
+ for component_name in ("main", "universe"):
+ self.factory.makeComponentSelection(distroseries, component_name)
+
+ build = self.factory.makeCIBuild(
+ distro_arch_series=distroarchseries, **kwargs)
+ return IBuildFarmJobBehaviour(build)
+
+
+class TestCIBuildBehaviour(TestCIBuildBehaviourBase):
+
+ def test_provides_interface(self):
+ # CIBuildBehaviour provides IBuildFarmJobBehaviour.
+ job = CIBuildBehaviour(None)
+ self.assertProvides(job, IBuildFarmJobBehaviour)
+
+ def test_adapts_ICIBuild(self):
+ # IBuildFarmJobBehaviour adapts an ICIBuild.
+ build = self.factory.makeCIBuild()
+ job = IBuildFarmJobBehaviour(build)
+ self.assertProvides(job, IBuildFarmJobBehaviour)
+
+ def test_verifyBuildRequest_valid(self):
+ # verifyBuildRequest doesn't raise any exceptions when called with a
+ # valid builder set.
+ job = self.makeJob()
+ lfa = self.factory.makeLibraryFileAlias(db_only=True)
+ job.build.distro_arch_series.addOrUpdateChroot(lfa)
+ builder = MockBuilder()
+ job.setBuilder(builder, OkWorker())
+ logger = BufferLogger()
+ job.verifyBuildRequest(logger)
+ self.assertEqual("", logger.getLogBuffer())
+
+ def test_verifyBuildRequest_virtual_mismatch(self):
+ # verifyBuildRequest raises on an attempt to build a virtualized
+ # build on a non-virtual builder.
+ job = self.makeJob()
+ lfa = self.factory.makeLibraryFileAlias(db_only=True)
+ job.build.distro_arch_series.addOrUpdateChroot(lfa)
+ builder = MockBuilder(virtualized=False)
+ job.setBuilder(builder, OkWorker())
+ logger = BufferLogger()
+ e = self.assertRaises(AssertionError, job.verifyBuildRequest, logger)
+ self.assertEqual(
+ "Attempt to build virtual item on a non-virtual builder.", str(e))
+
+ def test_verifyBuildRequest_no_chroot(self):
+ # verifyBuildRequest raises when the DAS has no chroot.
+ job = self.makeJob()
+ builder = MockBuilder()
+ job.setBuilder(builder, OkWorker())
+ logger = BufferLogger()
+ e = self.assertRaises(CannotBuild, job.verifyBuildRequest, logger)
+ self.assertIn("Missing chroot", str(e))
+
+
+_unset = object()
+
+
+class TestAsyncCIBuildBehaviour(StatsMixin, TestCIBuildBehaviourBase):
+
+ run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
+ timeout=30)
+
+ @defer.inlineCallbacks
+ def setUp(self):
+ super().setUp()
+ build_username = "CIBUILD-1"
+ self.token = {"secret": uuid.uuid4().hex,
+ "username": build_username,
+ "timestamp": datetime.utcnow().isoformat()}
+ self.proxy_url = ("http://{username}:{password}"
+ "@{host}:{port}".format(
+ username=self.token["username"],
+ password=self.token["secret"],
+ host=config.builddmaster.builder_proxy_host,
+ port=config.builddmaster.builder_proxy_port))
+ self.proxy_api = self.useFixture(InProcessProxyAuthAPIFixture())
+ yield self.proxy_api.start()
+ self.now = time.time()
+ self.useFixture(MockPatch("time.time", return_value=self.now))
+ self.addCleanup(shut_down_default_process_pool)
+ self.setUpStats()
+ self.addCleanup(
+ set_default_timeout_function, get_default_timeout_function())
+ set_default_timeout_function(lambda: None)
+
+ def makeJob(self, configuration=_unset, **kwargs):
+ # We need a builder in these tests, in order that requesting a proxy
+ # token can piggyback on its reactor and pool.
+ job = super().makeJob(**kwargs)
+ builder = MockBuilder()
+ builder.processor = job.build.processor
+ worker = self.useFixture(WorkerTestHelpers()).getClientWorker()
+ job.setBuilder(builder, worker)
+ self.addCleanup(worker.pool.closeCachedConnections)
+ if configuration is _unset:
+ # Skeleton configuration defining a single job.
+ configuration = dedent("""\
+ pipeline:
+ - [test]
+ jobs:
+ test:
+ series: {}
+ architectures: [{}]
+ """.format(
+ job.build.distro_arch_series.distroseries.name,
+ job.build.distro_arch_series.architecturetag)).encode()
+ hosting_fixture = self.useFixture(
+ GitHostingFixture(blob=configuration))
+ if configuration is None:
+ hosting_fixture.getBlob.failure = GitRepositoryBlobNotFound(
+ job.build.git_repository.getInternalPath(), ".launchpad.yaml",
+ rev=job.build.commit_sha1)
+ return job
+
+ @defer.inlineCallbacks
+ def test_composeBuildRequest(self):
+ job = self.makeJob()
+ lfa = self.factory.makeLibraryFileAlias(db_only=True)
+ job.build.distro_arch_series.addOrUpdateChroot(lfa)
+ build_request = yield job.composeBuildRequest(None)
+ self.assertThat(build_request, MatchesListwise([
+ Equals("ci"),
+ Equals(job.build.distro_arch_series),
+ Equals(job.build.pocket),
+ Equals({}),
+ IsInstance(dict),
+ ]))
+
+ @defer.inlineCallbacks
+ def test_requestProxyToken_unconfigured(self):
+ self.pushConfig(
+ "builddmaster", builder_proxy_auth_api_admin_secret=None)
+ job = self.makeJob()
+ expected_exception_msg = (
+ "builder_proxy_auth_api_admin_secret is not configured.")
+ with ExpectedException(CannotBuild, expected_exception_msg):
+ yield job.extraBuildArgs()
+
+ @defer.inlineCallbacks
+ def test_requestProxyToken(self):
+ job = self.makeJob()
+ yield job.extraBuildArgs()
+ expected_uri = urlsplit(
+ config.builddmaster.builder_proxy_auth_api_endpoint
+ ).path.encode("UTF-8")
+ self.assertThat(self.proxy_api.tokens.requests, MatchesListwise([
+ MatchesDict({
+ "method": Equals(b"POST"),
+ "uri": Equals(expected_uri),
+ "headers": ContainsDict({
+ b"Authorization": MatchesListwise([
+ Equals(b"Basic " + base64.b64encode(
+ b"admin-launchpad.test:admin-secret"))]),
+ b"Content-Type": MatchesListwise([
+ Equals(b"application/json"),
+ ]),
+ }),
+ "json": MatchesDict({
+ "username": StartsWith(job.build.build_cookie + "-"),
+ }),
+ }),
+ ]))
+
+ @defer.inlineCallbacks
+ def test_extraBuildArgs_git(self):
+ # extraBuildArgs returns appropriate arguments if asked to build a
+ # job for a Git commit.
+ job = self.makeJob()
+ expected_archives, expected_trusted_keys = (
+ yield get_sources_list_for_building(
+ job, job.build.distro_arch_series, None))
+ for archive_line in expected_archives:
+ self.assertIn("universe", archive_line)
+ with dbuser(config.builddmaster.dbuser):
+ args = yield job.extraBuildArgs()
+ self.assertThat(args, MatchesDict({
+ "archive_private": Is(False),
+ "archives": Equals(expected_archives),
+ "arch_tag": Equals("i386"),
+ "build_url": Equals(canonical_url(job.build)),
+ "commit_sha1": Equals(job.build.commit_sha1),
+ "fast_cleanup": Is(True),
+ "git_repository": Equals(job.build.git_repository.git_https_url),
+ "jobs": Equals([[("test", 0)]]),
+ "private": Is(False),
+ "proxy_url": ProxyURLMatcher(job, self.now),
+ "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
+ "series": Equals(job.build.distro_series.name),
+ "trusted_keys": Equals(expected_trusted_keys),
+ }))
+
+ @defer.inlineCallbacks
+ def test_extraBuildArgs_archive_trusted_keys(self):
+ # If the archive has a signing key, extraBuildArgs sends it.
+ yield self.useFixture(InProcessKeyServerFixture()).start()
+ job = self.makeJob()
+ distribution = job.build.distribution
+ key_path = os.path.join(gpgkeysdir, "ppa-sample@xxxxxxxxxxxxxxxxx")
+ yield IArchiveGPGSigningKey(distribution.main_archive).setSigningKey(
+ key_path, async_keyserver=True)
+ self.factory.makeBinaryPackagePublishingHistory(
+ distroarchseries=job.build.distro_arch_series,
+ pocket=job.build.pocket, archive=distribution.main_archive,
+ status=PackagePublishingStatus.PUBLISHED)
+ with dbuser(config.builddmaster.dbuser):
+ args = yield job.extraBuildArgs()
+ self.assertThat(args["trusted_keys"], MatchesListwise([
+ Base64KeyMatches("0D57E99656BEFB0897606EE9A022DD1F5001B46D"),
+ ]))
+
+ @defer.inlineCallbacks
+ def test_extraBuildArgs_archives_primary(self):
+ # The build uses the release, security, and updates pockets from the
+ # primary archive.
+ job = self.makeJob()
+ expected_archives = [
+ "deb %s %s main universe" % (
+ job.archive.archive_url, job.build.distro_series.name),
+ "deb %s %s-security main universe" % (
+ job.archive.archive_url, job.build.distro_series.name),
+ "deb %s %s-updates main universe" % (
+ job.archive.archive_url, job.build.distro_series.name),
+ ]
+ with dbuser(config.builddmaster.dbuser):
+ extra_args = yield job.extraBuildArgs()
+ self.assertEqual(expected_archives, extra_args["archives"])
+
+ @defer.inlineCallbacks
+ def test_extraBuildArgs_private(self):
+ # If the repository is private, extraBuildArgs sends the appropriate
+ # arguments.
+ repository = self.factory.makeGitRepository(
+ information_type=InformationType.USERDATA)
+ job = self.makeJob(git_repository=repository)
+ with dbuser(config.builddmaster.dbuser):
+ args = yield job.extraBuildArgs()
+ self.assertTrue(args["private"])
+
+ @defer.inlineCallbacks
+ def test_composeBuildRequest_proxy_url_set(self):
+ job = self.makeJob()
+ build_request = yield job.composeBuildRequest(None)
+ self.assertThat(
+ build_request[4]["proxy_url"], ProxyURLMatcher(job, self.now))
+
+ @defer.inlineCallbacks
+ def test_composeBuildRequest_unparseable(self):
+ # If the job's configuration file fails to parse,
+ # composeBuildRequest raises CannotBuild.
+ job = self.makeJob(configuration=b"")
+ expected_exception_msg = (
+ r"Cannot parse \.launchpad\.yaml from .*: "
+ r"Empty configuration file")
+ with ExpectedException(CannotBuild, expected_exception_msg):
+ yield job.composeBuildRequest(None)
+
+ @defer.inlineCallbacks
+ def test_composeBuildRequest_no_jobs_defined(self):
+ # If the job's configuration does not define any jobs,
+ # composeBuildRequest raises CannotBuild.
+ job = self.makeJob(configuration=b"pipeline: []\njobs: {}\n")
+ expected_exception_msg = re.escape(
+ "No jobs defined for %s:%s" % (
+ job.build.git_repository.unique_name, job.build.commit_sha1))
+ with ExpectedException(CannotBuild, expected_exception_msg):
+ yield job.composeBuildRequest(None)
+
+ @defer.inlineCallbacks
+ def test_composeBuildRequest_undefined_job(self):
+ # If the job's configuration has a pipeline that defines a job not
+ # in the jobs matrix, composeBuildRequest raises CannotBuild.
+ job = self.makeJob(configuration=b"pipeline: [test]\njobs: {}\n")
+ expected_exception_msg = re.escape(
+ "Job 'test' in pipeline for %s:%s but not in jobs" % (
+ job.build.git_repository.unique_name, job.build.commit_sha1))
+ with ExpectedException(CannotBuild, expected_exception_msg):
+ yield job.composeBuildRequest(None)
+
+ @defer.inlineCallbacks
+ def test_dispatchBuildToWorker_prefers_lxd(self):
+ self.pushConfig("builddmaster", builder_proxy_host=None)
+ job = self.makeJob()
+ builder = MockBuilder()
+ builder.processor = job.build.processor
+ worker = OkWorker()
+ job.setBuilder(builder, worker)
+ chroot_lfa = self.factory.makeLibraryFileAlias(db_only=True)
+ job.build.distro_arch_series.addOrUpdateChroot(
+ chroot_lfa, image_type=BuildBaseImageType.CHROOT)
+ lxd_lfa = self.factory.makeLibraryFileAlias(db_only=True)
+ job.build.distro_arch_series.addOrUpdateChroot(
+ lxd_lfa, image_type=BuildBaseImageType.LXD)
+ yield job.dispatchBuildToWorker(DevNullLogger())
+ self.assertEqual(
+ ("ensurepresent", lxd_lfa.http_url, "", ""), worker.call_log[0])
+ self.assertEqual(1, self.stats_client.incr.call_count)
+ self.assertEqual(
+ self.stats_client.incr.call_args_list[0][0],
+ ("build.count,builder_name={},env=test,"
+ "job_type=CIBUILD".format(builder.name),))
+
+ @defer.inlineCallbacks
+ def test_dispatchBuildToWorker_falls_back_to_chroot(self):
+ self.pushConfig("builddmaster", builder_proxy_host=None)
+ job = self.makeJob()
+ builder = MockBuilder()
+ builder.processor = job.build.processor
+ worker = OkWorker()
+ job.setBuilder(builder, worker)
+ chroot_lfa = self.factory.makeLibraryFileAlias(db_only=True)
+ job.build.distro_arch_series.addOrUpdateChroot(
+ chroot_lfa, image_type=BuildBaseImageType.CHROOT)
+ yield job.dispatchBuildToWorker(DevNullLogger())
+ self.assertEqual(
+ ("ensurepresent", chroot_lfa.http_url, "", ""), worker.call_log[0])
+
+
+class MakeCIBuildMixin:
+ """Provide the common makeBuild method returning a queued build."""
+
+ def makeBuild(self):
+ build = self.factory.makeCIBuild(status=BuildStatus.BUILDING)
+ das = build.distro_arch_series
+ self.useFixture(GitHostingFixture(blob=dedent("""\
+ pipeline:
+ - [test]
+ jobs:
+ test:
+ series: {}
+ architectures: [{}]
+ """.format(das.distroseries.name, das.architecturetag)).encode()))
+ build.queueBuild()
+ return build
+
+ def makeUnmodifiableBuild(self):
+ self.skipTest("Not relevant for CI builds.")
+
+
+class TestGetUploadMethodsForCIBuild(
+ MakeCIBuildMixin, TestGetUploadMethodsMixin, TestCaseWithFactory):
+ """IPackageBuild.getUpload* methods work with CI builds."""
+
+
+class TestVerifySuccessfulBuildForCIBuild(
+ MakeCIBuildMixin, TestVerifySuccessfulBuildMixin, TestCaseWithFactory):
+ """IBuildFarmJobBehaviour.verifySuccessfulBuild works with CI builds."""
+
+
+class TestHandleStatusForCIBuild(
+ MakeCIBuildMixin, TestHandleStatusMixin, TestCaseWithFactory):
+ """IPackageBuild.handleStatus works with CI builds."""
+
+ @defer.inlineCallbacks
+ def test_handleStatus_WAITING_OK_with_jobs(self):
+ # If the worker status includes a "jobs" item, then we additionally
+ # dump that to jobs.json.
+ with dbuser(config.builddmaster.dbuser):
+ yield self.behaviour.handleStatus(
+ self.build.buildqueue_record,
+ {"builder_status": "BuilderStatus.WAITING",
+ "build_status": "BuildStatus.OK",
+ "filemap": {"build:0.log": "test_file_hash"},
+ "jobs": {"build:0": {"log": "test_file_hash"}}})
+ jobs_path = os.path.join(
+ self.upload_root, "incoming",
+ self.behaviour.getUploadDirLeaf(self.build.build_cookie),
+ str(self.build.archive.id), self.build.distribution.name,
+ "jobs.json")
+ with open(jobs_path) as jobs_file:
+ self.assertEqual(
+ {"build:0": {"log": "test_file_hash"}}, json.load(jobs_file))
diff --git a/lib/lp/oci/model/ocirecipebuildbehaviour.py b/lib/lp/oci/model/ocirecipebuildbehaviour.py
index 14cf0ed..6939ca5 100644
--- a/lib/lp/oci/model/ocirecipebuildbehaviour.py
+++ b/lib/lp/oci/model/ocirecipebuildbehaviour.py
@@ -220,11 +220,12 @@ class OCIRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
return (filemap[file_name], file_path)
@defer.inlineCallbacks
- def _downloadFiles(self, filemap, upload_path, logger):
+ def _downloadFiles(self, worker_status, upload_path, logger):
"""Download required artifact files."""
+ filemap = worker_status["filemap"]
+
# We don't want to download all of the files that have been created,
# just the ones that are mentioned in the manifest and config.
-
manifest = yield self._fetchIntermediaryFile(
'manifest.json', filemap, upload_path)
digests = yield self._fetchIntermediaryFile(