Merge ~twom/launchpad:oci-file-upload-deduplication into launchpad:master

Proposed by Tom Wardill
Status: Merged
Approved by: Tom Wardill
Approved revision: 296522e8a4ccb0dc96c6fa206f56f9c4663521df
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~twom/launchpad:oci-file-upload-deduplication
Merge into: launchpad:master
Diff against target: 277 lines (+144/-4)
8 files modified
lib/lp/archiveuploader/ocirecipeupload.py (+12/-0)
lib/lp/archiveuploader/tests/test_ocirecipeupload.py (+28/-0)
lib/lp/oci/configure.zcml (+12/-0)
lib/lp/oci/interfaces/ocirecipebuild.py (+8/-0)
lib/lp/oci/model/ocirecipebuild.py (+11/-0)
lib/lp/oci/model/ocirecipebuildbehaviour.py (+11/-4)
lib/lp/oci/tests/test_ocirecipebuild.py (+27/-0)
lib/lp/oci/tests/test_ocirecipebuildbehaviour.py (+35/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Ioana Lasc (community) Approve
Thiago F. Pappacena (community) Approve
Review via email: mp+383859@code.launchpad.net

Commit message

Reuse existing image layers in new uploads

Description of the change

Rather than uploading each layer for each image to the librarian, reuse any that are existing.

This could introduce a race condition with garbage collection, but as we don't currently have any, it's safe for now, but should be considered in the design for garbo.

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Looks good!

review: Approve
Revision history for this message
Ioana Lasc (ilasc) wrote :

All makes sense to me and looks good code-wise.

review: Approve
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Tom Wardill (twom) :
59920d5... by Tom Wardill

Drop security.py declaration in favour of allow

Revision history for this message
Colin Watson (cjwatson) :
296522e... by Tom Wardill

Fix comment

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/archiveuploader/ocirecipeupload.py b/lib/lp/archiveuploader/ocirecipeupload.py
index c3db6cb..3ace0da 100644
--- a/lib/lp/archiveuploader/ocirecipeupload.py
+++ b/lib/lp/archiveuploader/ocirecipeupload.py
@@ -17,6 +17,7 @@ from zope.component import getUtility
1717
18from lp.archiveuploader.utils import UploadError18from lp.archiveuploader.utils import UploadError
19from lp.buildmaster.enums import BuildStatus19from lp.buildmaster.enums import BuildStatus
20from lp.oci.interfaces.ocirecipebuild import IOCIFileSet
20from lp.services.helpers import filenameToContentType21from lp.services.helpers import filenameToContentType
21from lp.services.librarian.interfaces import ILibraryFileAliasSet22from lp.services.librarian.interfaces import ILibraryFileAliasSet
2223
@@ -62,6 +63,17 @@ class OCIRecipeUpload:
62 "{}.tar.gz".format(layer_id)63 "{}.tar.gz".format(layer_id)
63 )64 )
64 self.logger.debug("Layer path: {}".format(layer_path))65 self.logger.debug("Layer path: {}".format(layer_path))
66 # If the file is already in the librarian,
67 # we can just reuse it.
68 existing_file = getUtility(IOCIFileSet).getByLayerDigest(
69 digest)
70 # XXX 2020-05-14 twom This will need to respect restricted
71 # when we do private builds.
72 if existing_file:
73 build.addFile(
74 existing_file.library_file,
75 layer_file_digest=digest)
76 continue
65 if not os.path.exists(layer_path):77 if not os.path.exists(layer_path):
66 raise UploadError(78 raise UploadError(
67 "Missing layer file: {}.".format(layer_id))79 "Missing layer file: {}.".format(layer_id))
diff --git a/lib/lp/archiveuploader/tests/test_ocirecipeupload.py b/lib/lp/archiveuploader/tests/test_ocirecipeupload.py
index bae4f4a..75e2455 100644
--- a/lib/lp/archiveuploader/tests/test_ocirecipeupload.py
+++ b/lib/lp/archiveuploader/tests/test_ocirecipeupload.py
@@ -113,3 +113,31 @@ class TestOCIRecipeUploads(TestUploadProcessorBase):
113 "ERROR Missing layer file: layer_2.",113 "ERROR Missing layer file: layer_2.",
114 self.log.getLogBuffer())114 self.log.getLogBuffer())
115 self.assertFalse(self.build.verifySuccessfulUpload())115 self.assertFalse(self.build.verifySuccessfulUpload())
116
117 def test_reuse_existing_file(self):
118 # The digests.json specifies a file that already exists in the
119 # librarian, but not on disk
120 self.assertFalse(self.build.verifySuccessfulUpload())
121 del get_property_cache(self.build).manifest
122 del get_property_cache(self.build).digests
123 upload_dir = os.path.join(
124 self.incoming_folder, "test", str(self.build.id), "ubuntu")
125 write_file(os.path.join(upload_dir, "layer_1.tar.gz"), b"layer_1")
126 write_file(os.path.join(upload_dir, "manifest.json"), b"manifest")
127 write_file(
128 os.path.join(upload_dir, "digests.json"), json.dumps(self.digests))
129
130 # create the existing file
131 self.switchToAdmin()
132 layer_2 = self.factory.makeOCIFile(layer_file_digest="digest_2")
133 Store.of(layer_2.build).flush()
134 self.switchToUploader()
135
136 handler = UploadHandler.forProcessor(
137 self.uploadprocessor, self.incoming_folder, "test", self.build)
138 result = handler.processOCIRecipe(self.log)
139 self.assertEqual(
140 UploadStatusEnum.ACCEPTED, result,
141 "OCI upload failed\nGot: %s" % self.log.getLogBuffer())
142 self.assertEqual(BuildStatus.FULLYBUILT, self.build.status)
143 self.assertTrue(self.build.verifySuccessfulUpload())
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 616fd2a..1f7a6c7 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -97,6 +97,18 @@
97 factory="lp.oci.model.ocirecipebuildbehaviour.OCIRecipeBuildBehaviour"97 factory="lp.oci.model.ocirecipebuildbehaviour.OCIRecipeBuildBehaviour"
98 permission="zope.Public" />98 permission="zope.Public" />
9999
100 <!-- OCIFile -->
101 <class class="lp.oci.model.ocirecipebuild.OCIFile">
102 <allow interface="lp.oci.interfaces.ocirecipebuild.IOCIFile" />
103 </class>
104
105 <!-- OCIFileSet -->
106 <securedutility
107 class="lp.oci.model.ocirecipebuild.OCIFileSet"
108 provides="lp.oci.interfaces.ocirecipebuild.IOCIFileSet">
109 <allow interface="lp.oci.interfaces.ocirecipebuild.IOCIFileSet" />
110 </securedutility>
111
100 <!-- OCIRegistryCredentials -->112 <!-- OCIRegistryCredentials -->
101 <class class="lp.oci.model.ociregistrycredentials.OCIRegistryCredentials">113 <class class="lp.oci.model.ociregistrycredentials.OCIRegistryCredentials">
102 <require114 <require
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 1f00269..95539c0 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
8__metaclass__ = type8__metaclass__ = type
9__all__ = [9__all__ = [
10 'IOCIFile',10 'IOCIFile',
11 'IOCIFileSet',
11 'IOCIRecipeBuild',12 'IOCIRecipeBuild',
12 'IOCIRecipeBuildSet',13 'IOCIRecipeBuildSet',
13 'OCIRecipeBuildRegistryUploadStatus',14 'OCIRecipeBuildRegistryUploadStatus',
@@ -82,6 +83,13 @@ class OCIRecipeBuildRegistryUploadStatus(EnumeratedType):
82 """)83 """)
8384
8485
86class IOCIFileSet(Interface):
87 """A file artifact of an OCIRecipeBuild."""
88
89 def getByLayerDigest(layer_file_digest):
90 """Return an `IOCIFile` with the matching layer_file_digest."""
91
92
85class IOCIRecipeBuildView(IPackageBuild):93class IOCIRecipeBuildView(IPackageBuild):
86 """`IOCIRecipeBuild` attributes that require launchpad.View permission."""94 """`IOCIRecipeBuild` attributes that require launchpad.View permission."""
8795
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 6b8e023..94b7326 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -43,6 +43,7 @@ from lp.buildmaster.model.packagebuild import PackageBuildMixin
43from lp.oci.interfaces.ocirecipe import IOCIRecipeSet43from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
44from lp.oci.interfaces.ocirecipebuild import (44from lp.oci.interfaces.ocirecipebuild import (
45 IOCIFile,45 IOCIFile,
46 IOCIFileSet,
46 IOCIRecipeBuild,47 IOCIRecipeBuild,
47 IOCIRecipeBuildSet,48 IOCIRecipeBuildSet,
48 OCIRecipeBuildRegistryUploadStatus,49 OCIRecipeBuildRegistryUploadStatus,
@@ -98,6 +99,16 @@ class OCIFile(Storm):
98 self.layer_file_digest = layer_file_digest99 self.layer_file_digest = layer_file_digest
99100
100101
102@implementer(IOCIFileSet)
103class OCIFileSet:
104
105 def getByLayerDigest(self, layer_file_digest):
106 return IStore(OCIFile).find(
107 OCIFile,
108 OCIFile.layer_file_digest == layer_file_digest).order_by(
109 OCIFile.id).first()
110
111
101@implementer(IOCIRecipeBuild)112@implementer(IOCIRecipeBuild)
102class OCIRecipeBuild(PackageBuildMixin, Storm):113class OCIRecipeBuild(PackageBuildMixin, Storm):
103114
diff --git a/lib/lp/oci/model/ocirecipebuildbehaviour.py b/lib/lp/oci/model/ocirecipebuildbehaviour.py
index dbef696..6205cfd 100644
--- a/lib/lp/oci/model/ocirecipebuildbehaviour.py
+++ b/lib/lp/oci/model/ocirecipebuildbehaviour.py
@@ -18,6 +18,7 @@ import json
18import os18import os
1919
20from twisted.internet import defer20from twisted.internet import defer
21from zope.component import getUtility
21from zope.interface import implementer22from zope.interface import implementer
2223
23from lp.app.errors import NotFoundError24from lp.app.errors import NotFoundError
@@ -32,6 +33,7 @@ from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
32from lp.buildmaster.model.buildfarmjobbehaviour import (33from lp.buildmaster.model.buildfarmjobbehaviour import (
33 BuildFarmJobBehaviourBase,34 BuildFarmJobBehaviourBase,
34 )35 )
36from lp.oci.interfaces.ocirecipebuild import IOCIFileSet
35from lp.registry.interfaces.series import SeriesStatus37from lp.registry.interfaces.series import SeriesStatus
36from lp.services.librarian.utils import copy_and_close38from lp.services.librarian.utils import copy_and_close
37from lp.snappy.model.snapbuildbehaviour import SnapProxyMixin39from lp.snappy.model.snapbuildbehaviour import SnapProxyMixin
@@ -134,12 +136,17 @@ class OCIRecipeBuildBehaviour(SnapProxyMixin, BuildFarmJobBehaviourBase):
134 # This is in the form '<id>/layer.tar', we only need the first136 # This is in the form '<id>/layer.tar', we only need the first
135 layer_filename = "{}.tar.gz".format(layer_id.split('/')[0])137 layer_filename = "{}.tar.gz".format(layer_id.split('/')[0])
136 digest = digests_section[diff_id]['digest']138 digest = digests_section[diff_id]['digest']
137 try:139 # Check if the file already exists in the librarian
138 _, librarian_file, _ = self.build.getLayerFileByDigest(140 oci_file = getUtility(IOCIFileSet).getByLayerDigest(
139 digest)141 digest)
140 except NotFoundError:142 if oci_file:
143 librarian_file = oci_file.library_file
144 # If it doesn't, we need to download it
145 else:
141 files.add(layer_filename)146 files.add(layer_filename)
142 continue147 continue
148 # If the file already exists, retrieve it from the librarian
149 # so we can add it to the build artifacts
143 layer_path = os.path.join(upload_path, layer_filename)150 layer_path = os.path.join(upload_path, layer_filename)
144 librarian_file.open()151 librarian_file.open()
145 copy_and_close(librarian_file, open(layer_path, 'wb'))152 copy_and_close(librarian_file, open(layer_path, 'wb'))
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 84a69f4..2ebffde 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -29,6 +29,7 @@ from lp.oci.interfaces.ocirecipe import (
29 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,29 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
30 )30 )
31from lp.oci.interfaces.ocirecipebuild import (31from lp.oci.interfaces.ocirecipebuild import (
32 IOCIFileSet,
32 IOCIRecipeBuild,33 IOCIRecipeBuild,
33 IOCIRecipeBuildSet,34 IOCIRecipeBuildSet,
34 )35 )
@@ -52,6 +53,32 @@ from lp.testing.layers import (
52from lp.testing.matchers import HasQueryCount53from lp.testing.matchers import HasQueryCount
5354
5455
56class TestOCIFileSet(TestCaseWithFactory):
57
58 layer = LaunchpadZopelessLayer
59
60 def setUp(self):
61 super(TestOCIFileSet, self).setUp()
62 self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
63
64 def test_implements_interface(self):
65 file_set = getUtility(IOCIFileSet)
66 self.assertProvides(file_set, IOCIFileSet)
67
68 def test_getByLayerDigest(self):
69 digest = "test_digest"
70 oci_file = self.factory.makeOCIFile(layer_file_digest=digest)
71 for _ in range(3):
72 self.factory.makeOCIFile()
73
74 result = getUtility(IOCIFileSet).getByLayerDigest(digest)
75 self.assertEqual(oci_file, result)
76
77 def test_getByLayerDigest_not_matching(self):
78 result = getUtility(IOCIFileSet).getByLayerDigest("not existing")
79 self.assertIsNone(result)
80
81
55class TestOCIRecipeBuild(TestCaseWithFactory):82class TestOCIRecipeBuild(TestCaseWithFactory):
5683
57 layer = LaunchpadZopelessLayer84 layer = LaunchpadZopelessLayer
diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
index 8536072..ee18740 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
@@ -519,6 +519,41 @@ class TestHandleStatusForOCIRecipeBuild(MakeOCIBuildMixin,
519 self.assertEqual(contents, b'retrieved from librarian')519 self.assertEqual(contents, b'retrieved from librarian')
520520
521 @defer.inlineCallbacks521 @defer.inlineCallbacks
522 def test_handleStatus_OK_reuse_from_other_build(self):
523 """We should be able to reuse a layer file from a separate build."""
524 self.factory.makeOCIFile(
525 layer_file_digest=u'digest_2',
526 content="layer 2 retrieved from librarian")
527
528 now = datetime.now()
529 mock_datetime = self.useFixture(MockPatch(
530 'lp.buildmaster.model.buildfarmjobbehaviour.datetime')).mock
531 mock_datetime.now = lambda: now
532 with dbuser(config.builddmaster.dbuser):
533 yield self.behaviour.handleStatus(
534 self.build.buildqueue_record, 'OK',
535 {'filemap': self.filemap})
536 self.assertEqual(
537 ['buildlog', 'manifest_hash', 'digests_hash', 'config_1_hash'],
538 self.slave._got_file_record)
539 # This hash should not appear as it is already in the librarian
540 self.assertNotIn('layer_1_hash', self.slave._got_file_record)
541 self.assertNotIn('layer_2_hash', self.slave._got_file_record)
542
543 # layer_2 should have been retrieved from the librarian
544 layer_2_path = os.path.join(
545 self.upload_root,
546 "incoming",
547 self.behaviour.getUploadDirLeaf(self.build.build_cookie),
548 str(self.build.archive.id),
549 self.build.distribution.name,
550 "layer_2.tar.gz"
551 )
552 with open(layer_2_path, 'rb') as layer_2_fp:
553 contents = layer_2_fp.read()
554 self.assertEqual(contents, b'layer 2 retrieved from librarian')
555
556 @defer.inlineCallbacks
522 def test_handleStatus_OK_absolute_filepath(self):557 def test_handleStatus_OK_absolute_filepath(self):
523558
524 self._createTestFile(559 self._createTestFile(

Subscribers

People subscribed via source and target branches

to status/vote changes: