Merge ~twom/launchpad:oci-buildbehaviour into launchpad:master

Proposed by Tom Wardill on 2020-02-14
Status: Merged
Approved by: Tom Wardill on 2020-03-03
Approved revision: 47145664aa7899c4393aff34ccbba9536ac7775b
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~twom/launchpad:oci-buildbehaviour
Merge into: launchpad:master
Diff against target: 1557 lines (+1067/-166)
15 files modified
database/schema/security.cfg (+1/-0)
lib/lp/buildmaster/model/buildfarmjobbehaviour.py (+17/-13)
lib/lp/buildmaster/tests/mock_slaves.py (+9/-3)
lib/lp/buildmaster/tests/snapbuildproxy.py (+123/-0)
lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py (+1/-1)
lib/lp/oci/configure.zcml (+6/-0)
lib/lp/oci/interfaces/ocirecipebuild.py (+6/-0)
lib/lp/oci/model/ocirecipebuild.py (+25/-4)
lib/lp/oci/model/ocirecipebuildbehaviour.py (+182/-0)
lib/lp/oci/tests/test_ocirecipebuild.py (+11/-1)
lib/lp/oci/tests/test_ocirecipebuildbehaviour.py (+604/-0)
lib/lp/snappy/model/snapbuildbehaviour.py (+52/-43)
lib/lp/snappy/tests/test_snapbuildbehaviour.py (+18/-96)
lib/lp/testing/factory.py (+11/-4)
lib/lp/translations/tests/test_translationtemplatesbuildbehaviour.py (+1/-1)
Reviewer Review Type Date Requested Status
Colin Watson 2020-02-14 Approve on 2020-03-03
Review via email: mp+379201@code.launchpad.net

This proposal supersedes a proposal from 2019-12-12.

Commit message

Add OCIRecipeBuildBehaviour

Description of the change

Add a buildbehaviour to drive OCI image builds.
Use mixin tests where sensible, override for places where the OCI methods differ.

To post a comment you must log in.
Colin Watson (cjwatson) : Posted in a previous version of this proposal
review: Needs Fixing
Tom Wardill (twom) : Posted in a previous version of this proposal
Colin Watson (cjwatson) : Posted in a previous version of this proposal
Colin Watson (cjwatson) :
review: Needs Fixing
Colin Watson (cjwatson) :
Colin Watson (cjwatson) :
review: Needs Fixing
Colin Watson (cjwatson) :
review: Approve
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 306d206..d0832c1 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -995,6 +995,7 @@ public.livefsbuild = SELECT, UPDATE
995public.livefsfile = SELECT995public.livefsfile = SELECT
996public.ocifile = SELECT996public.ocifile = SELECT
997public.ociproject = SELECT997public.ociproject = SELECT
998public.ociprojectname = SELECT
998public.ocirecipe = SELECT999public.ocirecipe = SELECT
999public.ocirecipebuild = SELECT, UPDATE1000public.ocirecipebuild = SELECT, UPDATE
1000public.openididentifier = SELECT1001public.openididentifier = SELECT
diff --git a/lib/lp/buildmaster/model/buildfarmjobbehaviour.py b/lib/lp/buildmaster/model/buildfarmjobbehaviour.py
index 2f3515a..4b28693 100644
--- a/lib/lp/buildmaster/model/buildfarmjobbehaviour.py
+++ b/lib/lp/buildmaster/model/buildfarmjobbehaviour.py
@@ -301,6 +301,22 @@ class BuildFarmJobBehaviourBase:
301 transaction.commit()301 transaction.commit()
302302
303 @defer.inlineCallbacks303 @defer.inlineCallbacks
304 def _downloadFiles(self, filemap, upload_path, logger):
305 filenames_to_download = []
306 for filename, sha1 in filemap.items():
307 logger.info("Grabbing file: %s (%s)" % (
308 filename, self._slave.getURL(sha1)))
309 out_file_name = os.path.join(upload_path, filename)
310 # If the evaluated output file name is not within our
311 # upload path, then we don't try to copy this or any
312 # subsequent files.
313 if not os.path.realpath(out_file_name).startswith(upload_path):
314 raise BuildDaemonError(
315 "Build returned a file named %r." % filename)
316 filenames_to_download.append((sha1, out_file_name))
317 yield self._slave.getFiles(filenames_to_download, logger=logger)
318
319 @defer.inlineCallbacks
304 def handleSuccess(self, slave_status, logger):320 def handleSuccess(self, slave_status, logger):
305 """Handle a package that built successfully.321 """Handle a package that built successfully.
306322
@@ -337,19 +353,7 @@ class BuildFarmJobBehaviourBase:
337 grab_dir, str(build.archive.id), build.distribution.name)353 grab_dir, str(build.archive.id), build.distribution.name)
338 os.makedirs(upload_path)354 os.makedirs(upload_path)
339355
340 filenames_to_download = []356 yield self._downloadFiles(filemap, upload_path, logger)
341 for filename, sha1 in filemap.items():
342 logger.info("Grabbing file: %s (%s)" % (
343 filename, self._slave.getURL(sha1)))
344 out_file_name = os.path.join(upload_path, filename)
345 # If the evaluated output file name is not within our
346 # upload path, then we don't try to copy this or any
347 # subsequent files.
348 if not os.path.realpath(out_file_name).startswith(upload_path):
349 raise BuildDaemonError(
350 "Build returned a file named %r." % filename)
351 filenames_to_download.append((sha1, out_file_name))
352 yield self._slave.getFiles(filenames_to_download, logger=logger)
353357
354 transaction.commit()358 transaction.commit()
355359
diff --git a/lib/lp/buildmaster/tests/mock_slaves.py b/lib/lp/buildmaster/tests/mock_slaves.py
index a19a85d..32633ea 100644
--- a/lib/lp/buildmaster/tests/mock_slaves.py
+++ b/lib/lp/buildmaster/tests/mock_slaves.py
@@ -194,7 +194,8 @@ class WaitingSlave(OkSlave):
194194
195 # By default, the slave only has a buildlog, but callsites195 # By default, the slave only has a buildlog, but callsites
196 # can update this list as needed.196 # can update this list as needed.
197 self.valid_file_hashes = ['buildlog']197 self.valid_files = {'buildlog': ''}
198 self._got_file_record = []
198199
199 def status(self):200 def status(self):
200 self.call_log.append('status')201 self.call_log.append('status')
@@ -208,12 +209,17 @@ class WaitingSlave(OkSlave):
208209
209 def getFile(self, hash, file_to_write):210 def getFile(self, hash, file_to_write):
210 self.call_log.append('getFile')211 self.call_log.append('getFile')
211 if hash in self.valid_file_hashes:212 if hash in self.valid_files:
212 content = "This is a %s" % hash
213 if isinstance(file_to_write, types.StringTypes):213 if isinstance(file_to_write, types.StringTypes):
214 file_to_write = open(file_to_write, 'wb')214 file_to_write = open(file_to_write, 'wb')
215 if not self.valid_files[hash]:
216 content = b"This is a %s" % hash
217 else:
218 with open(self.valid_files[hash], 'rb') as source:
219 content = source.read()
215 file_to_write.write(content)220 file_to_write.write(content)
216 file_to_write.close()221 file_to_write.close()
222 self._got_file_record.append(hash)
217 return defer.succeed(None)223 return defer.succeed(None)
218224
219225
diff --git a/lib/lp/buildmaster/tests/snapbuildproxy.py b/lib/lp/buildmaster/tests/snapbuildproxy.py
220new file mode 100644226new file mode 100644
index 0000000..21d973e
--- /dev/null
+++ b/lib/lp/buildmaster/tests/snapbuildproxy.py
@@ -0,0 +1,123 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Fixtures for dealing with the build time 'snap' HTTP proxy."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10from datetime import datetime
11import json
12from textwrap import dedent
13import uuid
14
15import fixtures
16from six.moves.urllib_parse import urlsplit
17from testtools.matchers import (
18 Equals,
19 HasLength,
20 MatchesStructure,
21 )
22from twisted.internet import (
23 defer,
24 endpoints,
25 reactor,
26 )
27from twisted.python.compat import nativeString
28from twisted.web import (
29 resource,
30 server,
31 )
32
33from lp.services.config import config
34
35
36class ProxyAuthAPITokensResource(resource.Resource):
37 """A test tokens resource for the proxy authentication API."""
38
39 isLeaf = True
40
41 def __init__(self):
42 resource.Resource.__init__(self)
43 self.requests = []
44
45 def render_POST(self, request):
46 content = request.content.read()
47 self.requests.append({
48 "method": request.method,
49 "uri": request.uri,
50 "headers": dict(request.requestHeaders.getAllRawHeaders()),
51 "content": content,
52 })
53 username = json.loads(content)["username"]
54 return json.dumps({
55 "username": username,
56 "secret": uuid.uuid4().hex,
57 "timestamp": datetime.utcnow().isoformat(),
58 })
59
60
61class InProcessProxyAuthAPIFixture(fixtures.Fixture):
62 """A fixture that pretends to be the proxy authentication API.
63
64 Users of this fixture must call the `start` method, which returns a
65 `Deferred`, and arrange for that to get back to the reactor. This is
66 necessary because the basic fixture API does not allow `setUp` to return
67 anything. For example:
68
69 class TestSomething(TestCase):
70
71 run_tests_with = AsynchronousDeferredRunTest.make_factory(
72 timeout=10)
73
74 @defer.inlineCallbacks
75 def setUp(self):
76 super(TestSomething, self).setUp()
77 yield self.useFixture(InProcessProxyAuthAPIFixture()).start()
78 """
79
80 @defer.inlineCallbacks
81 def start(self):
82 root = resource.Resource()
83 self.tokens = ProxyAuthAPITokensResource()
84 root.putChild("tokens", self.tokens)
85 endpoint = endpoints.serverFromString(reactor, nativeString("tcp:0"))
86 site = server.Site(self.tokens)
87 self.addCleanup(site.stopFactory)
88 port = yield endpoint.listen(site)
89 self.addCleanup(port.stopListening)
90 config.push("in-process-proxy-auth-api-fixture", dedent("""
91 [snappy]
92 builder_proxy_auth_api_admin_secret: admin-secret
93 builder_proxy_auth_api_endpoint: http://%s:%s/tokens
94 """) %
95 (port.getHost().host, port.getHost().port))
96 self.addCleanup(config.pop, "in-process-proxy-auth-api-fixture")
97
98
99class ProxyURLMatcher(MatchesStructure):
100 """Check that a string is a valid url for a snap build proxy."""
101
102 def __init__(self, job, now):
103 super(ProxyURLMatcher, self).__init__(
104 scheme=Equals("http"),
105 username=Equals("{}-{}".format(
106 job.build.build_cookie, int(now))),
107 password=HasLength(32),
108 hostname=Equals(config.snappy.builder_proxy_host),
109 port=Equals(config.snappy.builder_proxy_port),
110 path=Equals(""))
111
112 def match(self, matchee):
113 super(ProxyURLMatcher, self).match(urlsplit(matchee))
114
115
116class RevocationEndpointMatcher(Equals):
117 """Check that a string is a valid endpoint for proxy token revocation."""
118
119 def __init__(self, job, now):
120 super(RevocationEndpointMatcher, self).__init__(
121 "{}/{}-{}".format(
122 config.snappy.builder_proxy_auth_api_endpoint,
123 job.build.build_cookie, int(now)))
diff --git a/lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py b/lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py
index 697ff52..5046d8c 100644
--- a/lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py
+++ b/lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py
@@ -337,7 +337,7 @@ class TestHandleStatusMixin:
337 self.builder = self.factory.makeBuilder()337 self.builder = self.factory.makeBuilder()
338 self.build.buildqueue_record.markAsBuilding(self.builder)338 self.build.buildqueue_record.markAsBuilding(self.builder)
339 self.slave = WaitingSlave('BuildStatus.OK')339 self.slave = WaitingSlave('BuildStatus.OK')
340 self.slave.valid_file_hashes.append('test_file_hash')340 self.slave.valid_files['test_file_hash'] = ''
341 self.interactor = BuilderInteractor()341 self.interactor = BuilderInteractor()
342 self.behaviour = self.interactor.getBuildBehaviour(342 self.behaviour = self.interactor.getBuildBehaviour(
343 self.build.buildqueue_record, self.builder, self.slave)343 self.build.buildqueue_record, self.builder, self.slave)
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index b86afcb..a01178a 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -63,4 +63,10 @@
63 <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />63 <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
64 </securedutility>64 </securedutility>
6565
66 <adapter
67 for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
68 provides="lp.buildmaster.interfaces.buildfarmjobbehaviour.IBuildFarmJobBehaviour"
69 factory="lp.oci.model.ocirecipebuildbehaviour.OCIRecipeBuildBehaviour"
70 permission="zope.Public" />
71
66</configure>72</configure>
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 30c3835..a41e2c8 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -27,6 +27,7 @@ from lp.oci.interfaces.ocirecipe import IOCIRecipe
27from lp.services.database.constants import DEFAULT27from lp.services.database.constants import DEFAULT
28from lp.services.fields import PublicPersonChoice28from lp.services.fields import PublicPersonChoice
29from lp.services.librarian.interfaces import ILibraryFileAlias29from lp.services.librarian.interfaces import ILibraryFileAlias
30from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
3031
3132
32class IOCIRecipeBuildEdit(Interface):33class IOCIRecipeBuildEdit(Interface):
@@ -82,6 +83,11 @@ class IOCIRecipeBuildView(IPackageBuild):
82 :return: The corresponding `ILibraryFileAlias`.83 :return: The corresponding `ILibraryFileAlias`.
83 """84 """
8485
86 distro_arch_series = Reference(
87 IDistroArchSeries,
88 title=_("The series and architecture for which to build."),
89 required=True, readonly=True)
90
8591
86class IOCIRecipeBuildAdmin(Interface):92class IOCIRecipeBuildAdmin(Interface):
87 # XXX twom 2020-02-10 This will probably need rescore() implementing93 # XXX twom 2020-02-10 This will probably need rescore() implementing
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index c010817..12688cd 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -44,7 +44,9 @@ from lp.oci.interfaces.ocirecipebuild import (
44 IOCIRecipeBuild,44 IOCIRecipeBuild,
45 IOCIRecipeBuildSet,45 IOCIRecipeBuildSet,
46 )46 )
47from lp.registry.interfaces.pocket import PackagePublishingPocket
47from lp.registry.model.person import Person48from lp.registry.model.person import Person
49from lp.services.config import config
48from lp.services.database.bulk import load_related50from lp.services.database.bulk import load_related
49from lp.services.database.constants import DEFAULT51from lp.services.database.constants import DEFAULT
50from lp.services.database.decoratedresultset import DecoratedResultSet52from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -128,10 +130,16 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
128 build_farm_job_id = Int(name='build_farm_job', allow_none=False)130 build_farm_job_id = Int(name='build_farm_job', allow_none=False)
129 build_farm_job = Reference(build_farm_job_id, 'BuildFarmJob.id')131 build_farm_job = Reference(build_farm_job_id, 'BuildFarmJob.id')
130132
131 # Stub attributes to match the IPackageBuild interface that we133 # We only care about the pocket from a building environment POV,
132 # are not using in this implementation at this time.134 # it is not a target, nor referenced in the final build.
133 pocket = None135 pocket = PackagePublishingPocket.UPDATES
134 distro_series = None136
137 @property
138 def distro_series(self):
139 # XXX twom 2020-02-14 - This really needs to be set elsewhere,
140 # as this may not be an LTS release and ties the OCI target to
141 # a completely unrelated process.
142 return self.distribution.currentseries
135143
136 def __init__(self, build_farm_job, requester, recipe,144 def __init__(self, build_farm_job, requester, recipe,
137 processor, virtualized, date_created):145 processor, virtualized, date_created):
@@ -257,6 +265,19 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
257 # pillar isn't just a distribution265 # pillar isn't just a distribution
258 return self.recipe.oci_project.distribution266 return self.recipe.oci_project.distribution
259267
268 @property
269 def distro_arch_series(self):
270 return self.distribution.currentseries.getDistroArchSeriesByProcessor(
271 self.processor)
272
273 def notify(self, extra_info=None):
274 """See `IPackageBuild`."""
275 if not config.builddmaster.send_build_notification:
276 return
277 if self.status == BuildStatus.FULLYBUILT:
278 return
279 # XXX twom 2019-12-11 This should send mail
280
260281
261@implementer(IOCIRecipeBuildSet)282@implementer(IOCIRecipeBuildSet)
262class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):283class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
diff --git a/lib/lp/oci/model/ocirecipebuildbehaviour.py b/lib/lp/oci/model/ocirecipebuildbehaviour.py
263new file mode 100644284new file mode 100644
index 0000000..f0debfa
--- /dev/null
+++ b/lib/lp/oci/model/ocirecipebuildbehaviour.py
@@ -0,0 +1,182 @@
1# Copyright 2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""An `IBuildFarmJobBehaviour` for `OCIRecipeBuild`.
5
6Dispatches OCI image build jobs to build-farm slaves.
7"""
8
9from __future__ import absolute_import, print_function, unicode_literals
10
11__metaclass__ = type
12__all__ = [
13 'OCIRecipeBuildBehaviour',
14 ]
15
16
17import json
18import os
19
20from twisted.internet import defer
21from zope.interface import implementer
22
23from lp.app.errors import NotFoundError
24from lp.buildmaster.enums import BuildBaseImageType
25from lp.buildmaster.interfaces.builder import (
26 BuildDaemonError,
27 CannotBuild,
28 )
29from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
30 IBuildFarmJobBehaviour,
31 )
32from lp.buildmaster.model.buildfarmjobbehaviour import (
33 BuildFarmJobBehaviourBase,
34 )
35from lp.registry.interfaces.series import SeriesStatus
36from lp.services.librarian.utils import copy_and_close
37from lp.snappy.model.snapbuildbehaviour import SnapProxyMixin
38from lp.soyuz.adapters.archivedependencies import (
39 get_sources_list_for_building,
40 )
41
42
43@implementer(IBuildFarmJobBehaviour)
44class OCIRecipeBuildBehaviour(SnapProxyMixin, BuildFarmJobBehaviourBase):
45
46 builder_type = "oci"
47 image_types = [BuildBaseImageType.LXD, BuildBaseImageType.CHROOT]
48
49 def getLogFileName(self):
50 series = self.build.distro_series
51
52 # Examples:
53 # buildlog_oci_ubuntu_wily_amd64_name_FULLYBUILT.txt
54 return 'buildlog_oci_%s_%s_%s_%s_%s' % (
55 series.distribution.name, series.name,
56 self.build.processor.name, self.build.recipe.name,
57 self.build.status.name)
58
59 def verifyBuildRequest(self, logger):
60 """Assert some pre-build checks.
61
62 The build request is checked:
63 * Virtualized builds can't build on a non-virtual builder
64 * Ensure that we have a chroot
65 """
66 build = self.build
67 if build.virtualized and not self._builder.virtualized:
68 raise AssertionError(
69 "Attempt to build virtual item on a non-virtual builder.")
70
71 chroot = build.distro_arch_series.getChroot(pocket=build.pocket)
72 if chroot is None:
73 raise CannotBuild(
74 "Missing chroot for %s" % build.distro_arch_series.displayname)
75
76 @defer.inlineCallbacks
77 def extraBuildArgs(self, logger=None):
78 """
79 Return the extra arguments required by the slave for the given build.
80 """
81 build = self.build
82 args = yield super(OCIRecipeBuildBehaviour, self).extraBuildArgs(
83 logger=logger)
84 yield self.addProxyArgs(args)
85 # XXX twom 2020-02-17 This may need to be more complex, and involve
86 # distribution name.
87 args["name"] = build.recipe.name
88 args["archives"], args["trusted_keys"] = (
89 yield get_sources_list_for_building(
90 build, build.distro_arch_series, None,
91 tools_source=None, tools_fingerprint=None,
92 logger=logger))
93
94 args['build_file'] = build.recipe.build_file
95
96 if build.recipe.git_ref is not None:
97 args["git_repository"] = (
98 build.recipe.git_repository.git_https_url)
99 else:
100 raise CannotBuild(
101 "Source repository for ~%s/%s has been deleted." %
102 (build.recipe.owner.name, build.recipe.name))
103
104 if build.recipe.git_path != "HEAD":
105 args["git_path"] = build.recipe.git_ref.name
106
107 defer.returnValue(args)
108
109 def _ensureFilePath(self, file_name, file_path, upload_path):
110 # If the evaluated output file name is not within our
111 # upload path, then we don't try to copy this or any
112 # subsequent files.
113 if not os.path.normpath(file_path).startswith(upload_path + '/'):
114 raise BuildDaemonError(
115 "Build returned a file named '%s'." % file_name)
116
117 @defer.inlineCallbacks
118 def _fetchIntermediaryFile(self, name, filemap, upload_path):
119 file_hash = filemap[name]
120 file_path = os.path.join(upload_path, name)
121 self._ensureFilePath(name, file_path, upload_path)
122 yield self._slave.getFile(file_hash, file_path)
123
124 with open(file_path, 'r') as file_fp:
125 contents = json.load(file_fp)
126 defer.returnValue(contents)
127
128 def _extractLayerFiles(self, upload_path, section, config, digests, files):
129 # These are different sets of ids, in the same order
130 # layer_id is the filename, diff_id is the internal (docker) id
131 for diff_id in config['rootfs']['diff_ids']:
132 for digests_section in digests:
133 layer_id = digests_section[diff_id]['layer_id']
134 # This is in the form '<id>/layer.tar', we only need the first
135 layer_filename = "{}.tar.gz".format(layer_id.split('/')[0])
136 digest = digests_section[diff_id]['digest']
137 try:
138 _, librarian_file, _ = self.build.getLayerFileByDigest(
139 digest)
140 except NotFoundError:
141 files.add(layer_filename)
142 continue
143 layer_path = os.path.join(upload_path, layer_filename)
144 librarian_file.open()
145 copy_and_close(librarian_file, open(layer_path, 'wb'))
146
147 def _convertToRetrievableFile(self, upload_path, file_name, filemap):
148 file_path = os.path.join(upload_path, file_name)
149 self._ensureFilePath(file_name, file_path, upload_path)
150 return (filemap[file_name], file_path)
151
152 @defer.inlineCallbacks
153 def _downloadFiles(self, filemap, upload_path, logger):
154 """Download required artifact files."""
155 # We don't want to download all of the files that have been created,
156 # just the ones that are mentioned in the manifest and config.
157
158 manifest = yield self._fetchIntermediaryFile(
159 'manifest.json', filemap, upload_path)
160 digests = yield self._fetchIntermediaryFile(
161 'digests.json', filemap, upload_path)
162
163 files = set()
164 for section in manifest:
165 config = yield self._fetchIntermediaryFile(
166 section['Config'], filemap, upload_path)
167 self._extractLayerFiles(
168 upload_path, section, config, digests, files)
169
170 files_to_download = [
171 self._convertToRetrievableFile(upload_path, filename, filemap)
172 for filename in files]
173 yield self._slave.getFiles(files_to_download, logger=logger)
174
175 def verifySuccessfulBuild(self):
176 """See `IBuildFarmJobBehaviour`."""
177 # The implementation in BuildFarmJobBehaviourBase checks whether the
178 # target suite is modifiable in the target archive. However, an
179 # `OCIRecipeBuild` does not use an archive in this manner.
180 # We do, however, refuse to build for
181 # obsolete series.
182 assert self.build.distro_series.status != SeriesStatus.OBSOLETE
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index b768e1d..60aaf93 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -16,11 +16,13 @@ from lp.app.errors import NotFoundError
16from lp.buildmaster.enums import BuildStatus16from lp.buildmaster.enums import BuildStatus
17from lp.buildmaster.interfaces.buildqueue import IBuildQueue17from lp.buildmaster.interfaces.buildqueue import IBuildQueue
18from lp.buildmaster.interfaces.packagebuild import IPackageBuild18from lp.buildmaster.interfaces.packagebuild import IPackageBuild
19from lp.buildmaster.interfaces.processor import IProcessorSet
19from lp.oci.interfaces.ocirecipebuild import (20from lp.oci.interfaces.ocirecipebuild import (
20 IOCIRecipeBuild,21 IOCIRecipeBuild,
21 IOCIRecipeBuildSet,22 IOCIRecipeBuildSet,
22 )23 )
23from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet24from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet
25from lp.registry.interfaces.series import SeriesStatus
24from lp.services.propertycache import clear_property_cache26from lp.services.propertycache import clear_property_cache
25from lp.testing import (27from lp.testing import (
26 admin_logged_in,28 admin_logged_in,
@@ -148,8 +150,16 @@ class TestOCIRecipeBuildSet(TestCaseWithFactory):
148150
149 def test_new(self):151 def test_new(self):
150 requester = self.factory.makePerson()152 requester = self.factory.makePerson()
151 recipe = self.factory.makeOCIRecipe()153 distribution = self.factory.makeDistribution()
154 distroseries = self.factory.makeDistroSeries(
155 distribution=distribution, status=SeriesStatus.CURRENT)
156 processor = getUtility(IProcessorSet).getByName("386")
157 distro_arch_series = self.factory.makeDistroArchSeries(
158 distroseries=distroseries, architecturetag="i386",
159 processor=processor)
152 distro_arch_series = self.factory.makeDistroArchSeries()160 distro_arch_series = self.factory.makeDistroArchSeries()
161 oci_project = self.factory.makeOCIProject(pillar=distribution)
162 recipe = self.factory.makeOCIRecipe(oci_project=oci_project)
153 target = getUtility(IOCIRecipeBuildSet).new(163 target = getUtility(IOCIRecipeBuildSet).new(
154 requester, recipe, distro_arch_series)164 requester, recipe, distro_arch_series)
155 with admin_logged_in():165 with admin_logged_in():
diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
156new file mode 100644166new file mode 100644
index 0000000..e9ac224
--- /dev/null
+++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
@@ -0,0 +1,604 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for `OCIRecipeBuildBehaviour`."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10import base64
11from datetime import datetime
12import json
13import os
14import shutil
15import tempfile
16import time
17import uuid
18
19import fixtures
20from six.moves.urllib_parse import urlsplit
21from testtools import ExpectedException
22from testtools.matchers import (
23 AfterPreprocessing,
24 ContainsDict,
25 Equals,
26 Is,
27 IsInstance,
28 MatchesDict,
29 MatchesListwise,
30 StartsWith,
31 )
32from testtools.twistedsupport import (
33 AsynchronousDeferredRunTestForBrokenTwisted,
34 )
35from twisted.internet import defer
36from twisted.trial.unittest import TestCase as TrialTestCase
37from zope.component import getUtility
38from zope.security.proxy import removeSecurityProxy
39
40from lp.buildmaster.enums import (
41 BuildBaseImageType,
42 BuildStatus,
43 )
44from lp.buildmaster.interactor import BuilderInteractor
45from lp.buildmaster.interfaces.builder import (
46 BuildDaemonError,
47 CannotBuild,
48 )
49from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
50 IBuildFarmJobBehaviour,
51 )
52from lp.buildmaster.interfaces.processor import IProcessorSet
53from lp.buildmaster.tests.mock_slaves import (
54 MockBuilder,
55 OkSlave,
56 SlaveTestHelpers,
57 WaitingSlave,
58 )
59from lp.buildmaster.tests.snapbuildproxy import (
60 InProcessProxyAuthAPIFixture,
61 ProxyURLMatcher,
62 RevocationEndpointMatcher,
63 )
64from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
65 TestGetUploadMethodsMixin,
66 )
67from lp.oci.model.ocirecipebuildbehaviour import OCIRecipeBuildBehaviour
68from lp.registry.interfaces.series import SeriesStatus
69from lp.services.config import config
70from lp.services.log.logger import DevNullLogger
71from lp.services.webapp import canonical_url
72from lp.soyuz.adapters.archivedependencies import (
73 get_sources_list_for_building,
74 )
75from lp.testing import TestCaseWithFactory
76from lp.testing.dbuser import dbuser
77from lp.testing.factory import LaunchpadObjectFactory
78from lp.testing.fakemethod import FakeMethod
79from lp.testing.layers import LaunchpadZopelessLayer
80from lp.testing.mail_helpers import pop_notifications
81
82
83class MakeOCIBuildMixin:
84
85 def makeBuild(self):
86 build = self.factory.makeOCIRecipeBuild()
87 self.factory.makeDistroSeries(
88 distribution=build.recipe.oci_project.distribution,
89 status=SeriesStatus.CURRENT)
90 build.queueBuild()
91 return build
92
93 def makeUnmodifiableBuild(self):
94 build = self.factory.makeOCIRecipeBuild()
95 build.distro_arch_series = 'failed'
96 build.queueBuild()
97 return build
98
99 def makeJob(self, git_ref, recipe=None):
100 """Create a sample `IOCIRecipeBuildBehaviour`."""
101 if recipe is None:
102 build = self.factory.makeOCIRecipeBuild()
103 else:
104 build = self.factory.makeOCIRecipeBuild(recipe=recipe)
105 build.recipe.git_ref = git_ref
106
107 job = IBuildFarmJobBehaviour(build)
108 builder = MockBuilder()
109 builder.processor = job.build.processor
110 slave = self.useFixture(SlaveTestHelpers()).getClientSlave()
111 job.setBuilder(builder, slave)
112 self.addCleanup(slave.pool.closeCachedConnections)
113
114 # Taken from test_archivedependencies.py
115 for component_name in ("main", "universe"):
116 self.factory.makeComponentSelection(
117 build.distro_arch_series.distroseries, component_name)
118
119 return job
120
121
122class TestOCIBuildBehaviour(TestCaseWithFactory):
123
124 layer = LaunchpadZopelessLayer
125
126 def test_provides_interface(self):
127 # OCIRecipeBuildBehaviour provides IBuildFarmJobBehaviour.
128 job = OCIRecipeBuildBehaviour(self.factory.makeOCIRecipeBuild())
129 self.assertProvides(job, IBuildFarmJobBehaviour)
130
131 def test_adapts_IOCIRecipeBuild(self):
132 # IBuildFarmJobBehaviour adapts an IOCIRecipeBuild.
133 build = self.factory.makeOCIRecipeBuild()
134 job = IBuildFarmJobBehaviour(build)
135 self.assertProvides(job, IBuildFarmJobBehaviour)
136
137
138class TestAsyncOCIRecipeBuildBehaviour(MakeOCIBuildMixin, TestCaseWithFactory):
139
140 run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
141 timeout=10)
142 layer = LaunchpadZopelessLayer
143
144 @defer.inlineCallbacks
145 def setUp(self):
146 super(TestAsyncOCIRecipeBuildBehaviour, self).setUp()
147 build_username = 'OCIBUILD-1'
148 self.token = {'secret': uuid.uuid4().get_hex(),
149 'username': build_username,
150 'timestamp': datetime.utcnow().isoformat()}
151 self.proxy_url = ("http://{username}:{password}"
152 "@{host}:{port}".format(
153 username=self.token['username'],
154 password=self.token['secret'],
155 host=config.snappy.builder_proxy_host,
156 port=config.snappy.builder_proxy_port))
157 self.proxy_api = self.useFixture(InProcessProxyAuthAPIFixture())
158 yield self.proxy_api.start()
159 self.now = time.time()
160 self.useFixture(fixtures.MockPatch(
161 "time.time", return_value=self.now))
162
163 @defer.inlineCallbacks
164 def test_composeBuildRequest(self):
165 [ref] = self.factory.makeGitRefs()
166 job = self.makeJob(git_ref=ref)
167 lfa = self.factory.makeLibraryFileAlias(db_only=True)
168 job.build.distro_arch_series.addOrUpdateChroot(lfa)
169 build_request = yield job.composeBuildRequest(None)
170 self.assertThat(build_request, MatchesListwise([
171 Equals('oci'),
172 Equals(job.build.distro_arch_series),
173 Equals(job.build.pocket),
174 Equals({}),
175 IsInstance(dict),
176 ]))
177
178 @defer.inlineCallbacks
179 def test_requestProxyToken_unconfigured(self):
180 self.pushConfig("snappy", builder_proxy_auth_api_admin_secret=None)
181 [ref] = self.factory.makeGitRefs()
182 job = self.makeJob(git_ref=ref)
183 expected_exception_msg = (
184 "builder_proxy_auth_api_admin_secret is not configured.")
185 with ExpectedException(CannotBuild, expected_exception_msg):
186 yield job.extraBuildArgs()
187
188 @defer.inlineCallbacks
189 def test_requestProxyToken(self):
190 [ref] = self.factory.makeGitRefs()
191 job = self.makeJob(git_ref=ref)
192 yield job.extraBuildArgs()
193 self.assertThat(self.proxy_api.tokens.requests, MatchesListwise([
194 MatchesDict({
195 "method": Equals("POST"),
196 "uri": Equals(urlsplit(
197 config.snappy.builder_proxy_auth_api_endpoint).path),
198 "headers": ContainsDict({
199 b"Authorization": MatchesListwise([
200 Equals(b"Basic " + base64.b64encode(
201 b"admin-launchpad.test:admin-secret"))]),
202 b"Content-Type": MatchesListwise([
203 Equals(b"application/json; charset=UTF-8"),
204 ]),
205 }),
206 "content": AfterPreprocessing(json.loads, MatchesDict({
207 "username": StartsWith(job.build.build_cookie + "-"),
208 })),
209 }),
210 ]))
211
212 @defer.inlineCallbacks
213 def test_extraBuildArgs_git(self):
214 # extraBuildArgs returns appropriate arguments if asked to build a
215 # job for a Git branch.
216 [ref] = self.factory.makeGitRefs()
217 job = self.makeJob(git_ref=ref)
218 expected_archives, expected_trusted_keys = (
219 yield get_sources_list_for_building(
220 job.build, job.build.distro_arch_series, None))
221 for archive_line in expected_archives:
222 self.assertIn('universe', archive_line)
223 with dbuser(config.builddmaster.dbuser):
224 args = yield job.extraBuildArgs()
225 self.assertThat(args, MatchesDict({
226 "archive_private": Is(False),
227 "archives": Equals(expected_archives),
228 "arch_tag": Equals("i386"),
229 "build_file": Equals(job.build.recipe.build_file),
230 "build_url": Equals(canonical_url(job.build)),
231 "fast_cleanup": Is(True),
232 "git_repository": Equals(ref.repository.git_https_url),
233 "git_path": Equals(ref.name),
234 "name": Equals(job.build.recipe.name),
235 "proxy_url": ProxyURLMatcher(job, self.now),
236 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
237 "series": Equals(job.build.distro_arch_series.distroseries.name),
238 "trusted_keys": Equals(expected_trusted_keys),
239 }))
240
241 @defer.inlineCallbacks
242 def test_extraBuildArgs_git_HEAD(self):
243 # extraBuildArgs returns appropriate arguments if asked to build a
244 # job for the default branch in a Launchpad-hosted Git repository.
245 [ref] = self.factory.makeGitRefs()
246 removeSecurityProxy(ref.repository)._default_branch = ref.path
247 job = self.makeJob(git_ref=ref.repository.getRefByPath("HEAD"))
248 expected_archives, expected_trusted_keys = (
249 yield get_sources_list_for_building(
250 job.build, job.build.distro_arch_series, None))
251 for archive_line in expected_archives:
252 self.assertIn('universe', archive_line)
253 with dbuser(config.builddmaster.dbuser):
254 args = yield job.extraBuildArgs()
255 self.assertThat(args, MatchesDict({
256 "archive_private": Is(False),
257 "archives": Equals(expected_archives),
258 "arch_tag": Equals("i386"),
259 "build_file": Equals(job.build.recipe.build_file),
260 "build_url": Equals(canonical_url(job.build)),
261 "fast_cleanup": Is(True),
262 "git_repository": Equals(ref.repository.git_https_url),
263 "name": Equals(job.build.recipe.name),
264 "proxy_url": ProxyURLMatcher(job, self.now),
265 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
266 "series": Equals(job.build.distro_arch_series.distroseries.name),
267 "trusted_keys": Equals(expected_trusted_keys),
268 }))
269
270 @defer.inlineCallbacks
271 def test_composeBuildRequest_proxy_url_set(self):
272 [ref] = self.factory.makeGitRefs()
273 job = self.makeJob(git_ref=ref)
274 build_request = yield job.composeBuildRequest(None)
275 self.assertThat(
276 build_request[4]["proxy_url"], ProxyURLMatcher(job, self.now))
277
278 @defer.inlineCallbacks
279 def test_composeBuildRequest_git_ref_deleted(self):
280 # If the source Git reference has been deleted, composeBuildRequest
281 # raises CannotBuild.
282 repository = self.factory.makeGitRepository()
283 [ref] = self.factory.makeGitRefs(repository=repository)
284 owner = self.factory.makePerson(name="oci-owner")
285
286 distribution = self.factory.makeDistribution()
287 distroseries = self.factory.makeDistroSeries(
288 distribution=distribution, status=SeriesStatus.CURRENT)
289 processor = getUtility(IProcessorSet).getByName("386")
290 self.factory.makeDistroArchSeries(
291 distroseries=distroseries, architecturetag="i386",
292 processor=processor)
293
294 oci_project = self.factory.makeOCIProject(
295 pillar=distribution, registrant=owner)
296 recipe = self.factory.makeOCIRecipe(
297 oci_project=oci_project, registrant=owner, owner=owner,
298 git_ref=ref)
299 job = self.makeJob(ref, recipe=recipe)
300 repository.removeRefs([ref.path])
301 self.assertIsNone(job.build.recipe.git_ref)
302 expected_exception_msg = ("Source repository for "
303 "~oci-owner/{} has been deleted.".format(
304 recipe.name))
305 with ExpectedException(CannotBuild, expected_exception_msg):
306 yield job.composeBuildRequest(None)
307
308 @defer.inlineCallbacks
309 def test_dispatchBuildToSlave_prefers_lxd(self):
310 self.pushConfig("snappy", builder_proxy_host=None)
311 [ref] = self.factory.makeGitRefs()
312 job = self.makeJob(git_ref=ref)
313 builder = MockBuilder()
314 builder.processor = job.build.processor
315 slave = OkSlave()
316 job.setBuilder(builder, slave)
317 chroot_lfa = self.factory.makeLibraryFileAlias(db_only=True)
318 job.build.distro_arch_series.addOrUpdateChroot(
319 chroot_lfa, image_type=BuildBaseImageType.CHROOT)
320 lxd_lfa = self.factory.makeLibraryFileAlias(db_only=True)
321 job.build.distro_arch_series.addOrUpdateChroot(
322 lxd_lfa, image_type=BuildBaseImageType.LXD)
323 yield job.dispatchBuildToSlave(DevNullLogger())
324 self.assertEqual(
325 ('ensurepresent', lxd_lfa.http_url, '', ''), slave.call_log[0])
326
327 @defer.inlineCallbacks
328 def test_dispatchBuildToSlave_falls_back_to_chroot(self):
329 self.pushConfig("snappy", builder_proxy_host=None)
330 [ref] = self.factory.makeGitRefs()
331 job = self.makeJob(git_ref=ref)
332 builder = MockBuilder()
333 builder.processor = job.build.processor
334 slave = OkSlave()
335 job.setBuilder(builder, slave)
336 chroot_lfa = self.factory.makeLibraryFileAlias(db_only=True)
337 job.build.distro_arch_series.addOrUpdateChroot(
338 chroot_lfa, image_type=BuildBaseImageType.CHROOT)
339 yield job.dispatchBuildToSlave(DevNullLogger())
340 self.assertEqual(
341 ('ensurepresent', chroot_lfa.http_url, '', ''), slave.call_log[0])
342
343
344class TestHandleStatusForOCIRecipeBuild(MakeOCIBuildMixin, TrialTestCase,
345 fixtures.TestWithFixtures):
346 # This is mostly copied from TestHandleStatusMixin, however
347 # we can't use all of those tests, due to the way OCIRecipeBuildBehaviour
348 # parses the file contents, rather than just retrieving all that are
349 # available. There's also some differences in the filemap handling, as
350 # we need a much more complex filemap here.
351
352 layer = LaunchpadZopelessLayer
353
354 def pushConfig(self, section, **kwargs):
355 """Push some key-value pairs into a section of the config.
356
357 The config values will be restored during test tearDown.
358 """
359 # Taken from lp/testing.py as we're using TrialTestCase,
360 # not lp.testing.TestCase, as we need to handle the deferred
361 # correctly.
362 name = self.factory.getUniqueString()
363 body = '\n'.join("%s: %s" % (k, v) for k, v in kwargs.iteritems())
364 config.push(name, "\n[%s]\n%s\n" % (section, body))
365 self.addCleanup(config.pop, name)
366
367 def _createTestFile(self, name, content, hash):
368 path = os.path.join(self.test_files_dir, name)
369 with open(path, 'wb') as fp:
370 fp.write(content)
371 self.slave.valid_files[hash] = path
372
373 def setUp(self):
374 super(TestHandleStatusForOCIRecipeBuild, self).setUp()
375 self.factory = LaunchpadObjectFactory()
376 self.build = self.makeBuild()
377 # For the moment, we require a builder for the build so that
378 # handleStatus_OK can get a reference to the slave.
379 self.builder = self.factory.makeBuilder()
380 self.build.buildqueue_record.markAsBuilding(self.builder)
381 self.slave = WaitingSlave('BuildStatus.OK')
382 self.slave.valid_files['test_file_hash'] = ''
383 self.interactor = BuilderInteractor()
384 self.behaviour = self.interactor.getBuildBehaviour(
385 self.build.buildqueue_record, self.builder, self.slave)
386
387 # We overwrite the buildmaster root to use a temp directory.
388 tempdir = tempfile.mkdtemp()
389 self.addCleanup(shutil.rmtree, tempdir)
390 self.upload_root = tempdir
391 self.pushConfig('builddmaster', root=self.upload_root)
392
393 # We stub out our build's getUploaderCommand() method so
394 # we can check whether it was called as well as
395 # verifySuccessfulUpload().
396 removeSecurityProxy(self.build).verifySuccessfulUpload = FakeMethod(
397 result=True)
398
399 digests = [{
400 "diff_id_1": {
401 "digest": "digest_1",
402 "source": "test/base_1",
403 "layer_id": "layer_1"
404 },
405 "diff_id_2": {
406 "digest": "digest_2",
407 "source": "",
408 "layer_id": "layer_2"
409 }
410 }]
411
412 self.test_files_dir = tempfile.mkdtemp()
413 self._createTestFile('buildlog', '', 'buildlog')
414 self._createTestFile(
415 'manifest.json',
416 '[{"Config": "config_file_1.json", '
417 '"Layers": ["layer_1/layer.tar", "layer_2/layer.tar"]}]',
418 'manifest_hash')
419 self._createTestFile(
420 'digests.json',
421 json.dumps(digests),
422 'digests_hash')
423 self._createTestFile(
424 'config_file_1.json',
425 '{"rootfs": {"diff_ids": ["diff_id_1", "diff_id_2"]}}',
426 'config_1_hash')
427 self._createTestFile(
428 'layer_2.tar.gz',
429 '',
430 'layer_2_hash'
431 )
432
433 self.filemap = {
434 'manifest.json': 'manifest_hash',
435 'digests.json': 'digests_hash',
436 'config_file_1.json': 'config_1_hash',
437 'layer_1.tar.gz': 'layer_1_hash',
438 'layer_2.tar.gz': 'layer_2_hash'
439 }
440 self.factory.makeOCIFile(
441 build=self.build, layer_file_digest=u'digest_1',
442 content="retrieved from librarian")
443
444 def assertResultCount(self, count, result):
445 self.assertEqual(
446 1, len(os.listdir(os.path.join(self.upload_root, result))))
447
448 @defer.inlineCallbacks
449 def test_handleStatus_OK_normal_image(self):
450 with dbuser(config.builddmaster.dbuser):
451 yield self.behaviour.handleStatus(
452 self.build.buildqueue_record, 'OK',
453 {'filemap': self.filemap})
454 self.assertEqual(
455 ['buildlog', 'manifest_hash', 'digests_hash', 'config_1_hash',
456 'layer_2_hash'],
457 self.slave._got_file_record)
458 # This hash should not appear as it is already in the librarian
459 self.assertNotIn('layer_1_hash', self.slave._got_file_record)
460 self.assertEqual(BuildStatus.UPLOADING, self.build.status)
461 self.assertResultCount(1, "incoming")
462
463 # layer_1 should have been retrieved from the librarian
464 layer_1_path = os.path.join(
465 self.upload_root,
466 "incoming",
467 self.behaviour.getUploadDirLeaf(self.build.build_cookie),
468 str(self.build.archive.id),
469 self.build.distribution.name,
470 "layer_1.tar.gz"
471 )
472 with open(layer_1_path, 'rb') as layer_1_fp:
473 contents = layer_1_fp.read()
474 self.assertEqual(contents, b'retrieved from librarian')
475
476 @defer.inlineCallbacks
477 def test_handleStatus_OK_absolute_filepath(self):
478
479 self._createTestFile(
480 'manifest.json',
481 '[{"Config": "/notvalid/config_file_1.json", '
482 '"Layers": ["layer_1/layer.tar", "layer_2/layer.tar"]}]',
483 'manifest_hash')
484
485 self.filemap['/notvalid/config_file_1.json'] = 'config_1_hash'
486
487 # A filemap that tries to write to files outside of the upload
488 # directory will not be collected.
489 with ExpectedException(
490 BuildDaemonError,
491 "Build returned a file named "
492 "'/notvalid/config_file_1.json'."):
493 with dbuser(config.builddmaster.dbuser):
494 yield self.behaviour.handleStatus(
495 self.build.buildqueue_record, 'OK',
496 {'filemap': self.filemap})
497
498 @defer.inlineCallbacks
499 def test_handleStatus_OK_relative_filepath(self):
500
501 self._createTestFile(
502 'manifest.json',
503 '[{"Config": "../config_file_1.json", '
504 '"Layers": ["layer_1/layer.tar", "layer_2/layer.tar"]}]',
505 'manifest_hash')
506
507 self.filemap['../config_file_1.json'] = 'config_1_hash'
508 # A filemap that tries to write to files outside of
509 # the upload directory will not be collected.
510 with ExpectedException(
511 BuildDaemonError,
512 "Build returned a file named '../config_file_1.json'."):
513 with dbuser(config.builddmaster.dbuser):
514 yield self.behaviour.handleStatus(
515 self.build.buildqueue_record, 'OK',
516 {'filemap': self.filemap})
517
518 @defer.inlineCallbacks
519 def test_handleStatus_OK_sets_build_log(self):
520 # The build log is set during handleStatus.
521 self.assertEqual(None, self.build.log)
522 with dbuser(config.builddmaster.dbuser):
523 yield self.behaviour.handleStatus(
524 self.build.buildqueue_record, 'OK',
525 {'filemap': self.filemap})
526 self.assertNotEqual(None, self.build.log)
527
528 @defer.inlineCallbacks
529 def test_handleStatus_ABORTED_cancels_cancelling(self):
530 with dbuser(config.builddmaster.dbuser):
531 self.build.updateStatus(BuildStatus.CANCELLING)
532 yield self.behaviour.handleStatus(
533 self.build.buildqueue_record, "ABORTED", {})
534 self.assertEqual(0, len(pop_notifications()), "Notifications received")
535 self.assertEqual(BuildStatus.CANCELLED, self.build.status)
536
537 @defer.inlineCallbacks
538 def test_handleStatus_ABORTED_illegal_when_building(self):
539 self.builder.vm_host = "fake_vm_host"
540 self.behaviour = self.interactor.getBuildBehaviour(
541 self.build.buildqueue_record, self.builder, self.slave)
542 with dbuser(config.builddmaster.dbuser):
543 self.build.updateStatus(BuildStatus.BUILDING)
544 with ExpectedException(
545 BuildDaemonError,
546 "Build returned unexpected status: u'ABORTED'"):
547 yield self.behaviour.handleStatus(
548 self.build.buildqueue_record, "ABORTED", {})
549
550 @defer.inlineCallbacks
551 def test_handleStatus_ABORTED_cancelling_sets_build_log(self):
552 # If a build is intentionally cancelled, the build log is set.
553 self.assertEqual(None, self.build.log)
554 with dbuser(config.builddmaster.dbuser):
555 self.build.updateStatus(BuildStatus.CANCELLING)
556 yield self.behaviour.handleStatus(
557 self.build.buildqueue_record, "ABORTED", {})
558 self.assertNotEqual(None, self.build.log)
559
560 @defer.inlineCallbacks
561 def test_date_finished_set(self):
562 # The date finished is updated during handleStatus_OK.
563 self.assertEqual(None, self.build.date_finished)
564 with dbuser(config.builddmaster.dbuser):
565 yield self.behaviour.handleStatus(
566 self.build.buildqueue_record, 'OK',
567 {'filemap': self.filemap})
568 self.assertNotEqual(None, self.build.date_finished)
569
570 @defer.inlineCallbacks
571 def test_givenback_collection(self):
572 with ExpectedException(
573 BuildDaemonError,
574 "Build returned unexpected status: u'GIVENBACK'"):
575 with dbuser(config.builddmaster.dbuser):
576 yield self.behaviour.handleStatus(
577 self.build.buildqueue_record, "GIVENBACK", {})
578
579 @defer.inlineCallbacks
580 def test_builderfail_collection(self):
581 with ExpectedException(
582 BuildDaemonError,
583 "Build returned unexpected status: u'BUILDERFAIL'"):
584 with dbuser(config.builddmaster.dbuser):
585 yield self.behaviour.handleStatus(
586 self.build.buildqueue_record, "BUILDERFAIL", {})
587
588 @defer.inlineCallbacks
589 def test_invalid_status_collection(self):
590 with ExpectedException(
591 BuildDaemonError,
592 "Build returned unexpected status: u'BORKED'"):
593 with dbuser(config.builddmaster.dbuser):
594 yield self.behaviour.handleStatus(
595 self.build.buildqueue_record, "BORKED", {})
596
597
598class TestGetUploadMethodsForOCIRecipeBuild(
599 MakeOCIBuildMixin, TestGetUploadMethodsMixin, TrialTestCase):
600 """IPackageBuild.getUpload-related methods work with OCI recipe builds."""
601
602 def setUp(self):
603 super(TestGetUploadMethodsForOCIRecipeBuild, self).__init__(self)
604 self.factory = LaunchpadObjectFactory()
diff --git a/lib/lp/snappy/model/snapbuildbehaviour.py b/lib/lp/snappy/model/snapbuildbehaviour.py
index cda3cff..cbcba1d 100644
--- a/lib/lp/snappy/model/snapbuildbehaviour.py
+++ b/lib/lp/snappy/model/snapbuildbehaviour.py
@@ -11,6 +11,7 @@ from __future__ import absolute_import, print_function, unicode_literals
11__metaclass__ = type11__metaclass__ = type
12__all__ = [12__all__ = [
13 'SnapBuildBehaviour',13 'SnapBuildBehaviour',
14 'SnapProxyMixin',
14 ]15 ]
1516
16import base6417import base64
@@ -61,9 +62,58 @@ def format_as_rfc3339(timestamp):
61 return timestamp.replace(microsecond=0, tzinfo=None).isoformat() + 'Z'62 return timestamp.replace(microsecond=0, tzinfo=None).isoformat() + 'Z'
6263
6364
65class SnapProxyMixin:
66 """Methods for handling builds with the Snap Build Proxy enabled."""
67
68 @defer.inlineCallbacks
69 def addProxyArgs(self, args, allow_internet=True):
70 if config.snappy.builder_proxy_host and allow_internet:
71 token = yield self._requestProxyToken()
72 args["proxy_url"] = (
73 "http://{username}:{password}@{host}:{port}".format(
74 username=token['username'],
75 password=token['secret'],
76 host=config.snappy.builder_proxy_host,
77 port=config.snappy.builder_proxy_port))
78 args["revocation_endpoint"] = (
79 "{endpoint}/{token}".format(
80 endpoint=config.snappy.builder_proxy_auth_api_endpoint,
81 token=token['username']))
82
83 @defer.inlineCallbacks
84 def _requestProxyToken(self):
85 admin_username = config.snappy.builder_proxy_auth_api_admin_username
86 if not admin_username:
87 raise CannotBuild(
88 "builder_proxy_auth_api_admin_username is not configured.")
89 secret = config.snappy.builder_proxy_auth_api_admin_secret
90 if not secret:
91 raise CannotBuild(
92 "builder_proxy_auth_api_admin_secret is not configured.")
93 url = config.snappy.builder_proxy_auth_api_endpoint
94 if not secret:
95 raise CannotBuild(
96 "builder_proxy_auth_api_endpoint is not configured.")
97 timestamp = int(time.time())
98 proxy_username = '{build_id}-{timestamp}'.format(
99 build_id=self.build.build_cookie,
100 timestamp=timestamp)
101 auth_string = '{}:{}'.format(admin_username, secret).strip()
102 auth_header = b'Basic ' + base64.b64encode(auth_string)
103
104 response = yield treq.post(
105 url, headers={'Authorization': auth_header},
106 json={'username': proxy_username},
107 reactor=self._slave.reactor,
108 pool=self._slave.pool)
109 response = yield check_status(response)
110 token = yield treq.json_content(response)
111 defer.returnValue(token)
112
113
64@adapter(ISnapBuild)114@adapter(ISnapBuild)
65@implementer(IBuildFarmJobBehaviour)115@implementer(IBuildFarmJobBehaviour)
66class SnapBuildBehaviour(BuildFarmJobBehaviourBase):116class SnapBuildBehaviour(SnapProxyMixin, BuildFarmJobBehaviourBase):
67 """Dispatches `SnapBuild` jobs to slaves."""117 """Dispatches `SnapBuild` jobs to slaves."""
68118
69 builder_type = "snap"119 builder_type = "snap"
@@ -111,18 +161,7 @@ class SnapBuildBehaviour(BuildFarmJobBehaviourBase):
111 build = self.build161 build = self.build
112 args = yield super(SnapBuildBehaviour, self).extraBuildArgs(162 args = yield super(SnapBuildBehaviour, self).extraBuildArgs(
113 logger=logger)163 logger=logger)
114 if config.snappy.builder_proxy_host and build.snap.allow_internet:164 yield self.addProxyArgs(args, build.snap.allow_internet)
115 token = yield self._requestProxyToken()
116 args["proxy_url"] = (
117 "http://{username}:{password}@{host}:{port}".format(
118 username=token['username'],
119 password=token['secret'],
120 host=config.snappy.builder_proxy_host,
121 port=config.snappy.builder_proxy_port))
122 args["revocation_endpoint"] = (
123 "{endpoint}/{token}".format(
124 endpoint=config.snappy.builder_proxy_auth_api_endpoint,
125 token=token['username']))
126 args["name"] = build.snap.store_name or build.snap.name165 args["name"] = build.snap.store_name or build.snap.name
127 channels = build.channels or {}166 channels = build.channels or {}
128 if "snapcraft" not in channels:167 if "snapcraft" not in channels:
@@ -188,36 +227,6 @@ class SnapBuildBehaviour(BuildFarmJobBehaviourBase):
188 args["build_request_timestamp"] = timestamp227 args["build_request_timestamp"] = timestamp
189 defer.returnValue(args)228 defer.returnValue(args)
190229
191 @defer.inlineCallbacks
192 def _requestProxyToken(self):
193 admin_username = config.snappy.builder_proxy_auth_api_admin_username
194 if not admin_username:
195 raise CannotBuild(
196 "builder_proxy_auth_api_admin_username is not configured.")
197 secret = config.snappy.builder_proxy_auth_api_admin_secret
198 if not secret:
199 raise CannotBuild(
200 "builder_proxy_auth_api_admin_secret is not configured.")
201 url = config.snappy.builder_proxy_auth_api_endpoint
202 if not secret:
203 raise CannotBuild(
204 "builder_proxy_auth_api_endpoint is not configured.")
205 timestamp = int(time.time())
206 proxy_username = '{build_id}-{timestamp}'.format(
207 build_id=self.build.build_cookie,
208 timestamp=timestamp)
209 auth_string = '{}:{}'.format(admin_username, secret).strip()
210 auth_header = b'Basic ' + base64.b64encode(auth_string)
211
212 response = yield treq.post(
213 url, headers={'Authorization': auth_header},
214 json={'username': proxy_username},
215 reactor=self._slave.reactor,
216 pool=self._slave.pool)
217 response = yield check_status(response)
218 token = yield treq.json_content(response)
219 defer.returnValue(token)
220
221 def verifySuccessfulBuild(self):230 def verifySuccessfulBuild(self):
222 """See `IBuildFarmJobBehaviour`."""231 """See `IBuildFarmJobBehaviour`."""
223 # The implementation in BuildFarmJobBehaviourBase checks whether the232 # The implementation in BuildFarmJobBehaviourBase checks whether the
diff --git a/lib/lp/snappy/tests/test_snapbuildbehaviour.py b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
index 2755523..f851008 100644
--- a/lib/lp/snappy/tests/test_snapbuildbehaviour.py
+++ b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
@@ -24,7 +24,6 @@ from testtools.matchers import (
24 AfterPreprocessing,24 AfterPreprocessing,
25 ContainsDict,25 ContainsDict,
26 Equals,26 Equals,
27 HasLength,
28 Is,27 Is,
29 IsInstance,28 IsInstance,
30 MatchesDict,29 MatchesDict,
@@ -38,13 +37,10 @@ from testtools.twistedsupport import (
38import transaction37import transaction
39from twisted.internet import (38from twisted.internet import (
40 defer,39 defer,
41 endpoints,
42 reactor,40 reactor,
43 )41 )
44from twisted.python.compat import nativeString
45from twisted.trial.unittest import TestCase as TrialTestCase42from twisted.trial.unittest import TestCase as TrialTestCase
46from twisted.web import (43from twisted.web import (
47 resource,
48 server,44 server,
49 xmlrpc,45 xmlrpc,
50 )46 )
@@ -71,6 +67,11 @@ from lp.buildmaster.tests.mock_slaves import (
71 OkSlave,67 OkSlave,
72 SlaveTestHelpers,68 SlaveTestHelpers,
73 )69 )
70from lp.buildmaster.tests.snapbuildproxy import (
71 InProcessProxyAuthAPIFixture,
72 ProxyURLMatcher,
73 RevocationEndpointMatcher,
74 )
74from lp.buildmaster.tests.test_buildfarmjobbehaviour import (75from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
75 TestGetUploadMethodsMixin,76 TestGetUploadMethodsMixin,
76 TestHandleStatusMixin,77 TestHandleStatusMixin,
@@ -100,7 +101,6 @@ from lp.soyuz.adapters.archivedependencies import (
100 )101 )
101from lp.soyuz.enums import PackagePublishingStatus102from lp.soyuz.enums import PackagePublishingStatus
102from lp.soyuz.interfaces.archive import ArchiveDisabled103from lp.soyuz.interfaces.archive import ArchiveDisabled
103from lp.soyuz.interfaces.component import IComponentSet
104from lp.soyuz.tests.soyuz import Base64KeyMatches104from lp.soyuz.tests.soyuz import Base64KeyMatches
105from lp.testing import (105from lp.testing import (
106 TestCase,106 TestCase,
@@ -116,69 +116,6 @@ from lp.testing.layers import LaunchpadZopelessLayer
116from lp.xmlrpc.interfaces import IPrivateApplication116from lp.xmlrpc.interfaces import IPrivateApplication
117117
118118
119class ProxyAuthAPITokensResource(resource.Resource):
120 """A test tokens resource for the proxy authentication API."""
121
122 isLeaf = True
123
124 def __init__(self):
125 resource.Resource.__init__(self)
126 self.requests = []
127
128 def render_POST(self, request):
129 content = request.content.read()
130 self.requests.append({
131 "method": request.method,
132 "uri": request.uri,
133 "headers": dict(request.requestHeaders.getAllRawHeaders()),
134 "content": content,
135 })
136 username = json.loads(content)["username"]
137 return json.dumps({
138 "username": username,
139 "secret": uuid.uuid4().hex,
140 "timestamp": datetime.utcnow().isoformat(),
141 })
142
143
144class InProcessProxyAuthAPIFixture(fixtures.Fixture):
145 """A fixture that pretends to be the proxy authentication API.
146
147 Users of this fixture must call the `start` method, which returns a
148 `Deferred`, and arrange for that to get back to the reactor. This is
149 necessary because the basic fixture API does not allow `setUp` to return
150 anything. For example:
151
152 class TestSomething(TestCase):
153
154 run_tests_with = AsynchronousDeferredRunTest.make_factory(
155 timeout=10)
156
157 @defer.inlineCallbacks
158 def setUp(self):
159 super(TestSomething, self).setUp()
160 yield self.useFixture(InProcessProxyAuthAPIFixture()).start()
161 """
162
163 @defer.inlineCallbacks
164 def start(self):
165 root = resource.Resource()
166 self.tokens = ProxyAuthAPITokensResource()
167 root.putChild("tokens", self.tokens)
168 endpoint = endpoints.serverFromString(reactor, nativeString("tcp:0"))
169 site = server.Site(self.tokens)
170 self.addCleanup(site.stopFactory)
171 port = yield endpoint.listen(site)
172 self.addCleanup(port.stopListening)
173 config.push("in-process-proxy-auth-api-fixture", dedent("""
174 [snappy]
175 builder_proxy_auth_api_admin_secret: admin-secret
176 builder_proxy_auth_api_endpoint: http://%s:%s/tokens
177 """) %
178 (port.getHost().host, port.getHost().port))
179 self.addCleanup(config.pop, "in-process-proxy-auth-api-fixture")
180
181
182class InProcessAuthServer(xmlrpc.XMLRPC):119class InProcessAuthServer(xmlrpc.XMLRPC):
183120
184 def __init__(self, *args, **kwargs):121 def __init__(self, *args, **kwargs):
@@ -380,21 +317,6 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
380 self.addCleanup(slave.pool.closeCachedConnections)317 self.addCleanup(slave.pool.closeCachedConnections)
381 return job318 return job
382319
383 def getProxyURLMatcher(self, job):
384 return AfterPreprocessing(urlsplit, MatchesStructure(
385 scheme=Equals("http"),
386 username=Equals("{}-{}".format(
387 job.build.build_cookie, int(self.now))),
388 password=HasLength(32),
389 hostname=Equals(config.snappy.builder_proxy_host),
390 port=Equals(config.snappy.builder_proxy_port),
391 path=Equals("")))
392
393 def getRevocationEndpointMatcher(self, job):
394 return Equals("{}/{}-{}".format(
395 config.snappy.builder_proxy_auth_api_endpoint,
396 job.build.build_cookie, int(self.now)))
397
398 @defer.inlineCallbacks320 @defer.inlineCallbacks
399 def test_composeBuildRequest(self):321 def test_composeBuildRequest(self):
400 job = self.makeJob()322 job = self.makeJob()
@@ -466,8 +388,8 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
466 "fast_cleanup": Is(True),388 "fast_cleanup": Is(True),
467 "name": Equals("test-snap"),389 "name": Equals("test-snap"),
468 "private": Is(False),390 "private": Is(False),
469 "proxy_url": self.getProxyURLMatcher(job),391 "proxy_url": ProxyURLMatcher(job, self.now),
470 "revocation_endpoint": self.getRevocationEndpointMatcher(job),392 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
471 "series": Equals("unstable"),393 "series": Equals("unstable"),
472 "trusted_keys": Equals(expected_trusted_keys),394 "trusted_keys": Equals(expected_trusted_keys),
473 }))395 }))
@@ -507,8 +429,8 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
507 "git_path": Equals(ref.name),429 "git_path": Equals(ref.name),
508 "name": Equals("test-snap"),430 "name": Equals("test-snap"),
509 "private": Is(False),431 "private": Is(False),
510 "proxy_url": self.getProxyURLMatcher(job),432 "proxy_url": ProxyURLMatcher(job, self.now),
511 "revocation_endpoint": self.getRevocationEndpointMatcher(job),433 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
512 "series": Equals("unstable"),434 "series": Equals("unstable"),
513 "trusted_keys": Equals(expected_trusted_keys),435 "trusted_keys": Equals(expected_trusted_keys),
514 }))436 }))
@@ -537,8 +459,8 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
537 "git_repository": Equals(ref.repository.git_https_url),459 "git_repository": Equals(ref.repository.git_https_url),
538 "name": Equals("test-snap"),460 "name": Equals("test-snap"),
539 "private": Is(False),461 "private": Is(False),
540 "proxy_url": self.getProxyURLMatcher(job),462 "proxy_url": ProxyURLMatcher(job, self.now),
541 "revocation_endpoint": self.getRevocationEndpointMatcher(job),463 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
542 "series": Equals("unstable"),464 "series": Equals("unstable"),
543 "trusted_keys": Equals(expected_trusted_keys),465 "trusted_keys": Equals(expected_trusted_keys),
544 }))466 }))
@@ -584,8 +506,8 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
584 "git_path": Equals(ref.name),506 "git_path": Equals(ref.name),
585 "name": Equals("test-snap"),507 "name": Equals("test-snap"),
586 "private": Is(True),508 "private": Is(True),
587 "proxy_url": self.getProxyURLMatcher(job),509 "proxy_url": ProxyURLMatcher(job, self.now),
588 "revocation_endpoint": self.getRevocationEndpointMatcher(job),510 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
589 "series": Equals("unstable"),511 "series": Equals("unstable"),
590 "trusted_keys": Equals(expected_trusted_keys),512 "trusted_keys": Equals(expected_trusted_keys),
591 }))513 }))
@@ -616,8 +538,8 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
616 "git_path": Equals("master"),538 "git_path": Equals("master"),
617 "name": Equals("test-snap"),539 "name": Equals("test-snap"),
618 "private": Is(False),540 "private": Is(False),
619 "proxy_url": self.getProxyURLMatcher(job),541 "proxy_url": ProxyURLMatcher(job, self.now),
620 "revocation_endpoint": self.getRevocationEndpointMatcher(job),542 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
621 "series": Equals("unstable"),543 "series": Equals("unstable"),
622 "trusted_keys": Equals(expected_trusted_keys),544 "trusted_keys": Equals(expected_trusted_keys),
623 }))545 }))
@@ -646,8 +568,8 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
646 "git_repository": Equals(url),568 "git_repository": Equals(url),
647 "name": Equals("test-snap"),569 "name": Equals("test-snap"),
648 "private": Is(False),570 "private": Is(False),
649 "proxy_url": self.getProxyURLMatcher(job),571 "proxy_url": ProxyURLMatcher(job, self.now),
650 "revocation_endpoint": self.getRevocationEndpointMatcher(job),572 "revocation_endpoint": RevocationEndpointMatcher(job, self.now),
651 "series": Equals("unstable"),573 "series": Equals("unstable"),
652 "trusted_keys": Equals(expected_trusted_keys),574 "trusted_keys": Equals(expected_trusted_keys),
653 }))575 }))
@@ -808,7 +730,7 @@ class TestAsyncSnapBuildBehaviour(TestSnapBuildBehaviourBase):
808 job = self.makeJob()730 job = self.makeJob()
809 build_request = yield job.composeBuildRequest(None)731 build_request = yield job.composeBuildRequest(None)
810 self.assertThat(732 self.assertThat(
811 build_request[4]["proxy_url"], self.getProxyURLMatcher(job))733 build_request[4]["proxy_url"], ProxyURLMatcher(job, self.now))
812734
813 @defer.inlineCallbacks735 @defer.inlineCallbacks
814 def test_composeBuildRequest_deleted(self):736 def test_composeBuildRequest_deleted(self):
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 85a05b6..c402c4c 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4991,9 +4991,15 @@ class BareLaunchpadObjectFactory(ObjectFactory):
4991 if requester is None:4991 if requester is None:
4992 requester = self.makePerson()4992 requester = self.makePerson()
4993 if distro_arch_series is None:4993 if distro_arch_series is None:
4994 distro_arch_series = self.makeDistroArchSeries()4994 distroseries = self.makeDistroSeries(status=SeriesStatus.CURRENT)
4995 processor = getUtility(IProcessorSet).getByName("386")
4996 distro_arch_series = self.makeDistroArchSeries(
4997 distroseries=distroseries, architecturetag="i386",
4998 processor=processor)
4995 if recipe is None:4999 if recipe is None:
4996 recipe = self.makeOCIRecipe()5000 oci_project = self.makeOCIProject(
5001 pillar=distro_arch_series.distroseries.distribution)
5002 recipe = self.makeOCIRecipe(oci_project=oci_project)
4997 oci_build = getUtility(IOCIRecipeBuildSet).new(5003 oci_build = getUtility(IOCIRecipeBuildSet).new(
4998 requester, recipe, distro_arch_series, date_created)5004 requester, recipe, distro_arch_series, date_created)
4999 if duration is not None:5005 if duration is not None:
@@ -5010,12 +5016,13 @@ class BareLaunchpadObjectFactory(ObjectFactory):
5010 return oci_build5016 return oci_build
50115017
5012 def makeOCIFile(self, build=None, library_file=None,5018 def makeOCIFile(self, build=None, library_file=None,
5013 layer_file_digest=None):5019 layer_file_digest=None, content=None, filename=None):
5014 """Make a new OCIFile."""5020 """Make a new OCIFile."""
5015 if build is None:5021 if build is None:
5016 build = self.makeOCIRecipeBuild()5022 build = self.makeOCIRecipeBuild()
5017 if library_file is None:5023 if library_file is None:
5018 library_file = self.makeLibraryFileAlias()5024 library_file = self.makeLibraryFileAlias(
5025 content=content, filename=filename)
5019 return OCIFile(build=build, library_file=library_file,5026 return OCIFile(build=build, library_file=library_file,
5020 layer_file_digest=layer_file_digest)5027 layer_file_digest=layer_file_digest)
50215028
diff --git a/lib/lp/translations/tests/test_translationtemplatesbuildbehaviour.py b/lib/lp/translations/tests/test_translationtemplatesbuildbehaviour.py
index b387251..32dd553 100644
--- a/lib/lp/translations/tests/test_translationtemplatesbuildbehaviour.py
+++ b/lib/lp/translations/tests/test_translationtemplatesbuildbehaviour.py
@@ -138,7 +138,7 @@ class TestTranslationTemplatesBuildBehaviour(
138 buildqueue = FakeBuildQueue(behaviour)138 buildqueue = FakeBuildQueue(behaviour)
139 path = behaviour.templates_tarball_path139 path = behaviour.templates_tarball_path
140 # Poke the file we're expecting into the mock slave.140 # Poke the file we're expecting into the mock slave.
141 behaviour._slave.valid_file_hashes.append(path)141 behaviour._slave.valid_files[path] = ''
142142
143 def got_tarball(filename):143 def got_tarball(filename):
144 tarball = open(filename, 'r')144 tarball = open(filename, 'r')

Subscribers

People subscribed via source and target branches