← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Tom Wardill has proposed merging ~twom/launchpad:oci-file-upload-deduplication into launchpad:master.

Commit message:
Reuse existing image layers in new uploads

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/383859

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-file-upload-deduplication into launchpad:master.
diff --git a/lib/lp/archiveuploader/ocirecipeupload.py b/lib/lp/archiveuploader/ocirecipeupload.py
index c3db6cb..33ce648 100644
--- a/lib/lp/archiveuploader/ocirecipeupload.py
+++ b/lib/lp/archiveuploader/ocirecipeupload.py
@@ -17,6 +17,7 @@ from zope.component import getUtility
 
 from lp.archiveuploader.utils import UploadError
 from lp.buildmaster.enums import BuildStatus
+from lp.oci.interfaces.ocirecipebuild import IOCIFileSet
 from lp.services.helpers import filenameToContentType
 from lp.services.librarian.interfaces import ILibraryFileAliasSet
 
@@ -62,6 +63,15 @@ class OCIRecipeUpload:
                         "{}.tar.gz".format(layer_id)
                     )
                     self.logger.debug("Layer path: {}".format(layer_path))
+                    # If the file is already in the librarian, the file
+                    # won't exist on disk, so we can just reuse it.
+                    existing_file = getUtility(IOCIFileSet).getByLayerDigest(
+                        digest)
+                    if existing_file:
+                        build.addFile(
+                            existing_file.library_file,
+                            layer_file_digest=digest)
+                        continue
                     if not os.path.exists(layer_path):
                         raise UploadError(
                             "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):
             "ERROR Missing layer file: layer_2.",
             self.log.getLogBuffer())
         self.assertFalse(self.build.verifySuccessfulUpload())
+
+    def test_reuse_existing_file(self):
+        # The digests.json specifies a file that already exists in the
+        # librarian, but not on disk
+        self.assertFalse(self.build.verifySuccessfulUpload())
+        del get_property_cache(self.build).manifest
+        del get_property_cache(self.build).digests
+        upload_dir = os.path.join(
+            self.incoming_folder, "test", str(self.build.id), "ubuntu")
+        write_file(os.path.join(upload_dir, "layer_1.tar.gz"), b"layer_1")
+        write_file(os.path.join(upload_dir, "manifest.json"), b"manifest")
+        write_file(
+            os.path.join(upload_dir, "digests.json"), json.dumps(self.digests))
+
+        # create the existing file
+        self.switchToAdmin()
+        layer_2 = self.factory.makeOCIFile(layer_file_digest="digest_2")
+        Store.of(layer_2.build).flush()
+        self.switchToUploader()
+
+        handler = UploadHandler.forProcessor(
+            self.uploadprocessor, self.incoming_folder, "test", self.build)
+        result = handler.processOCIRecipe(self.log)
+        self.assertEqual(
+            UploadStatusEnum.ACCEPTED, result,
+            "OCI upload failed\nGot: %s" % self.log.getLogBuffer())
+        self.assertEqual(BuildStatus.FULLYBUILT, self.build.status)
+        self.assertTrue(self.build.verifySuccessfulUpload())
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 616fd2a..a7cce5e 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -97,6 +97,20 @@
         factory="lp.oci.model.ocirecipebuildbehaviour.OCIRecipeBuildBehaviour"
         permission="zope.Public" />
 
+    <!-- OCIFile -->
+    <class class="lp.oci.model.ocirecipebuild.OCIFile">
+        <require
+            permission="launchpad.View"
+            interface="lp.oci.interfaces.ocirecipebuild.IOCIFile" />
+    </class>
+
+    <!-- OCIFileSet -->
+    <securedutility
+        class="lp.oci.model.ocirecipebuild.OCIFileSet"
+        provides="lp.oci.interfaces.ocirecipebuild.IOCIFileSet">
+        <allow interface="lp.oci.interfaces.ocirecipebuild.IOCIFileSet" />
+    </securedutility>
+
     <!-- OCIRegistryCredentials -->
     <class class="lp.oci.model.ociregistrycredentials.OCIRegistryCredentials">
         <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
 __metaclass__ = type
 __all__ = [
     'IOCIFile',
+    'IOCIFileSet',
     'IOCIRecipeBuild',
     'IOCIRecipeBuildSet',
     'OCIRecipeBuildRegistryUploadStatus',
@@ -82,6 +83,13 @@ class OCIRecipeBuildRegistryUploadStatus(EnumeratedType):
         """)
 
 
+class IOCIFileSet(Interface):
+    """A file artifact of an OCIRecipeBuild."""
+
+    def getByLayerDigest(layer_file_digest):
+        """Return an `IOCIFile` with the matching layer_file_digest."""
+
+
 class IOCIRecipeBuildView(IPackageBuild):
     """`IOCIRecipeBuild` attributes that require launchpad.View permission."""
 
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
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.oci.interfaces.ocirecipebuild import (
     IOCIFile,
+    IOCIFileSet,
     IOCIRecipeBuild,
     IOCIRecipeBuildSet,
     OCIRecipeBuildRegistryUploadStatus,
@@ -98,6 +99,16 @@ class OCIFile(Storm):
         self.layer_file_digest = layer_file_digest
 
 
+@implementer(IOCIFileSet)
+class OCIFileSet:
+
+    def getByLayerDigest(self, layer_file_digest):
+        return IStore(OCIFile).find(
+            OCIFile,
+            OCIFile.layer_file_digest == layer_file_digest).order_by(
+                OCIFile.id).first()
+
+
 @implementer(IOCIRecipeBuild)
 class OCIRecipeBuild(PackageBuildMixin, Storm):
 
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
 import os
 
 from twisted.internet import defer
+from zope.component import getUtility
 from zope.interface import implementer
 
 from lp.app.errors import NotFoundError
@@ -32,6 +33,7 @@ from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
 from lp.buildmaster.model.buildfarmjobbehaviour import (
     BuildFarmJobBehaviourBase,
     )
+from lp.oci.interfaces.ocirecipebuild import IOCIFileSet
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.librarian.utils import copy_and_close
 from lp.snappy.model.snapbuildbehaviour import SnapProxyMixin
@@ -134,12 +136,17 @@ class OCIRecipeBuildBehaviour(SnapProxyMixin, BuildFarmJobBehaviourBase):
                 # This is in the form '<id>/layer.tar', we only need the first
                 layer_filename = "{}.tar.gz".format(layer_id.split('/')[0])
                 digest = digests_section[diff_id]['digest']
-                try:
-                    _, librarian_file, _ = self.build.getLayerFileByDigest(
-                        digest)
-                except NotFoundError:
+                # Check if the file already exists in the librarian
+                oci_file = getUtility(IOCIFileSet).getByLayerDigest(
+                    digest)
+                if oci_file:
+                    librarian_file = oci_file.library_file
+                # If it doesn't, we need to download it
+                else:
                     files.add(layer_filename)
                     continue
+                # If the file already exists, retrieve it from the librarian
+                # so we can add it to the build artifacts
                 layer_path = os.path.join(upload_path, layer_filename)
                 librarian_file.open()
                 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 (
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     )
 from lp.oci.interfaces.ocirecipebuild import (
+    IOCIFileSet,
     IOCIRecipeBuild,
     IOCIRecipeBuildSet,
     )
@@ -52,6 +53,32 @@ from lp.testing.layers import (
 from lp.testing.matchers import HasQueryCount
 
 
+class TestOCIFileSet(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestOCIFileSet, self).setUp()
+        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+
+    def test_implements_interface(self):
+        file_set = getUtility(IOCIFileSet)
+        self.assertProvides(file_set, IOCIFileSet)
+
+    def test_getByLayerDigest(self):
+        digest = "test_digest"
+        oci_file = self.factory.makeOCIFile(layer_file_digest=digest)
+        for _ in range(3):
+            self.factory.makeOCIFile()
+
+        result = getUtility(IOCIFileSet).getByLayerDigest(digest)
+        self.assertEqual(oci_file, result)
+
+    def test_getByLayerDigest_not_matching(self):
+        result = getUtility(IOCIFileSet).getByLayerDigest("not existing")
+        self.assertIsNone(result)
+
+
 class TestOCIRecipeBuild(TestCaseWithFactory):
 
     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,
             self.assertEqual(contents, b'retrieved from librarian')
 
     @defer.inlineCallbacks
+    def test_handleStatus_OK_reuse_from_other_build(self):
+        """We should be able to reuse a layer file from a separate build."""
+        self.factory.makeOCIFile(
+            layer_file_digest=u'digest_2',
+            content="layer 2 retrieved from librarian")
+
+        now = datetime.now()
+        mock_datetime = self.useFixture(MockPatch(
+            'lp.buildmaster.model.buildfarmjobbehaviour.datetime')).mock
+        mock_datetime.now = lambda: now
+        with dbuser(config.builddmaster.dbuser):
+            yield self.behaviour.handleStatus(
+                self.build.buildqueue_record, 'OK',
+                {'filemap': self.filemap})
+        self.assertEqual(
+            ['buildlog', 'manifest_hash', 'digests_hash', 'config_1_hash'],
+            self.slave._got_file_record)
+        # This hash should not appear as it is already in the librarian
+        self.assertNotIn('layer_1_hash', self.slave._got_file_record)
+        self.assertNotIn('layer_2_hash', self.slave._got_file_record)
+
+        # layer_2 should have been retrieved from the librarian
+        layer_2_path = os.path.join(
+            self.upload_root,
+            "incoming",
+            self.behaviour.getUploadDirLeaf(self.build.build_cookie),
+            str(self.build.archive.id),
+            self.build.distribution.name,
+            "layer_2.tar.gz"
+        )
+        with open(layer_2_path, 'rb') as layer_2_fp:
+            contents = layer_2_fp.read()
+            self.assertEqual(contents, b'layer 2 retrieved from librarian')
+
+    @defer.inlineCallbacks
     def test_handleStatus_OK_absolute_filepath(self):
 
         self._createTestFile(
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 0eec9f3..cbef34d 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -117,7 +117,10 @@ from lp.oci.interfaces.ocirecipe import (
     IOCIRecipe,
     IOCIRecipeBuildRequest,
     )
-from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+from lp.oci.interfaces.ocirecipebuild import (
+    IOCIFile,
+    IOCIRecipeBuild,
+    )
 from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentials
 from lp.registry.enums import PersonVisibility
 from lp.registry.interfaces.announcement import IAnnouncement
@@ -3547,6 +3550,11 @@ class AdminOCIRecipeBuild(AdminByBuilddAdmin):
     usedfor = IOCIRecipeBuild
 
 
+class ViewOCIFile(AnonymousAuthorization):
+    """Anyone can view an `IOCIFile`."""
+    usedfor = IOCIFile
+
+
 class ViewOCIRegistryCredentials(AuthorizationBase):
     permission = 'launchpad.View'
     usedfor = IOCIRegistryCredentials