← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master with ~twom/launchpad:oci-policy-distribute-the-credentials as a prerequisite.

Commit message:
Use distribution credentials in OCI upload

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/395984
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index f18779d..1e04c97 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -4,6 +4,8 @@
 """OCI recipe views."""
 
 from __future__ import absolute_import, print_function, unicode_literals
+import ipdb
+
 
 __metaclass__ = type
 __all__ = [
@@ -739,6 +741,15 @@ class OCIRecipeFormMixin:
             build_args[k] = v
         data['build_args'] = build_args
 
+    @property
+    def use_distribution_credentials(self):
+        if hasattr(self.context, 'oci_project'):
+            project = self.context.oci_project
+        else:
+            project = self.context
+        distro = project.distribution
+        return bool(distro and distro.oci_registry_credentials)
+
 
 class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
                        OCIRecipeFormMixin):
@@ -772,6 +783,14 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
             "The architectures that this OCI recipe builds for. Some "
             "architectures are restricted and may only be enabled or "
             "disabled by administrators.")
+        if self.use_distribution_credentials:
+            self.form_fields += FormFields(TextLine(
+                __name__='image_name',
+                title=u"Image name",
+                description=(
+                    "Name to use for registry upload. "
+                    "Defaults to the name of the recipe."),
+                required=False, readonly=False))
 
     def setUpWidgets(self):
         """See `LaunchpadFormView`."""
@@ -816,7 +835,9 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
             oci_project=self.context, git_ref=data["git_ref"],
             build_file=data["build_file"], description=data["description"],
             build_daily=data["build_daily"], build_args=data["build_args"],
-            build_path=data["build_path"], processors=data["processors"])
+            build_path=data["build_path"], processors=data["processors"],
+            # image_name is only available if using distribution credentials.
+            image_name=data.get("image_name"))
         self.next_url = canonical_url(recipe)
 
 
@@ -888,6 +909,14 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
             "The architectures that this OCI recipe builds for. Some "
             "architectures are restricted and may only be enabled or "
             "disabled by administrators.")
+        if self.use_distribution_credentials:
+            self.form_fields += FormFields(TextLine(
+                __name__='image_name',
+                title=u"Image name",
+                description=(
+                    "Name to use for registry upload. "
+                    "Defaults to the name of the recipe."),
+                required=False, readonly=False))
 
     def validate(self, data):
         """See `LaunchpadFormView`."""
@@ -924,6 +953,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
                         data["processors"].append(processor)
         self.validateBuildArgs(data)
 
+
 class OCIRecipeDeleteView(BaseOCIRecipeEditView):
     """View for deleting OCI recipes."""
 
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index e22665d..ce20835 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -6,6 +6,9 @@
 
 from __future__ import absolute_import, print_function, unicode_literals
 
+from lp.soyuz.browser.archive import DistributionArchiveURL
+
+
 __metaclass__ = type
 
 from datetime import (
@@ -243,6 +246,29 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
             "Build-time\nARG variables:\nVAR1=10\nVAR2=20",
             MatchesTagText(content, "build-args"))
 
+    def test_create_new_recipe_with_image_name(self):
+        oci_project = self.factory.makeOCIProject()
+        credentials = self.factory.makeOCIRegistryCredentials()
+        with person_logged_in(oci_project.distribution.owner):
+            oci_project.distribution.oci_registry_credentials = credentials
+        [git_ref] = self.factory.makeGitRefs()
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "recipe-name"
+        browser.getControl("Description").value = "Recipe description"
+        browser.getControl(name="field.git_ref.repository").value = (
+            git_ref.repository.identity)
+        browser.getControl(name="field.git_ref.path").value = git_ref.path
+
+        image_name = self.factory.getUniqueUnicode()
+        browser.getControl(name="field.image_name").value = image_name
+        browser.getControl("Create OCI recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertThat(
+            "Registry image name\n{}".format(image_name),
+            MatchesTagText(content, "image-name"))
+
     def test_create_new_recipe_users_teams_as_owner_options(self):
         # Teams that the user is in are options for the OCI recipe owner.
         self.factory.makeTeam(
@@ -539,6 +565,25 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
         IStore(recipe).reload(recipe)
         self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args)
 
+    def test_edit_image_name(self):
+        self.setUpDistroSeries()
+        credentials = self.factory.makeOCIRegistryCredentials()
+        with person_logged_in(self.distribution.owner):
+            self.distribution.oci_registry_credentials = credentials
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, oci_project=oci_project)
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        image_name = self.factory.getUniqueUnicode()
+        field = browser.getControl(name="field.image_name")
+        field.value = image_name
+        browser.getControl("Update OCI recipe").click()
+        content = find_main_content(browser.contents)
+        self.assertThat(
+            "Registry image name\n{}".format(image_name),
+            MatchesTagText(content, "image-name"))
+
     def test_edit_with_invisible_processor(self):
         # It's possible for existing recipes to have an enabled processor
         # that's no longer usable with the current distroseries, which will
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 1f7a6c7..86c235b 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -155,6 +155,18 @@
             interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
     </securedutility>
 
+    <!-- OCIDistributionPushRule -->
+    <class class="lp.oci.model.ocipushrule.OCIDistributionPushRule">
+        <require
+            permission="launchpad.View"
+            interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleView
+                       lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleEdit"
+            set_schema="lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
+    </class>
+
     <!-- OCI related jobs -->
     <securedutility
         component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 131c4d3..51cf370 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -280,6 +280,11 @@ class IOCIRecipeView(Interface):
             "Whether everything is set up to allow uploading builds of "
             "this OCI recipe to a registry."))
 
+    use_distribution_credentials = Bool(
+        title=_("Use Distribution credentials"), required=True, readonly=True,
+        description=_("Use the credentials on a Distribution for "
+                      "registry upload"))
+
     def requestBuild(requester, architecture):
         """Request that the OCI recipe is built.
 
@@ -434,6 +439,13 @@ class IOCIRecipeEditableAttributes(IHasOwner):
         description=_("If True, this recipe should be built daily."),
         readonly=False))
 
+    image_name = exported(TextLine(
+        title=_("Image name"),
+        description=_("Image name to use on upload to registry. "
+                      "Defaults to recipe name if not set."),
+        required=False,
+        readonly=False))
+
 
 class IOCIRecipeAdminAttributes(Interface):
     """`IOCIRecipe` attributes that can be edited by admins.
diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
index 24d5e27..f7736e6 100644
--- a/lib/lp/oci/model/ocipushrule.py
+++ b/lib/lp/oci/model/ocipushrule.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    'OCIDistributionPushRule',
     'OCIPushRule',
     'OCIPushRuleSet',
     ]
@@ -72,6 +73,27 @@ class OCIPushRule(Storm):
         IStore(OCIPushRule).remove(self)
 
 
+@implementer(IOCIPushRule)
+class OCIDistributionPushRule:
+    """A none-database instance that is synthesised from data elsewhere."""
+
+    registry_credentials = None
+
+    def __init__(self, recipe, registry_credentials, image_name):
+        self.id = -1
+        self.recipe = recipe
+        self.registry_credentials = registry_credentials
+        self.image_name = image_name
+
+    @property
+    def registry_url(self):
+        return self.registry_credentials.url
+
+    @property
+    def username(self):
+        return self.registry_credentials.username
+
+
 @implementer(IOCIPushRuleSet)
 class OCIPushRuleSet:
 
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index ab7f655..34fd348 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -74,7 +74,10 @@ from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
 from lp.oci.interfaces.ociregistrycredentials import (
     IOCIRegistryCredentialsSet,
     )
-from lp.oci.model.ocipushrule import OCIPushRule
+from lp.oci.model.ocipushrule import (
+    OCIDistributionPushRule,
+    OCIPushRule,
+    )
 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
 from lp.oci.model.ocirecipejob import OCIRecipeJob
 from lp.registry.interfaces.distribution import IDistributionSet
@@ -160,10 +163,13 @@ class OCIRecipe(Storm, WebhookTargetMixin):
 
     build_daily = Bool(name="build_daily", default=False)
 
+    _image_name = Unicode(name="image_name", allow_none=True)
+
     def __init__(self, name, registrant, owner, oci_project, git_ref,
                  description=None, official=False, require_virtualized=True,
                  build_file=None, build_daily=False, date_created=DEFAULT,
-                 allow_internet=True, build_args=None, build_path=None):
+                 allow_internet=True, build_args=None, build_path=None,
+                 image_name=None):
         if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
             raise OCIRecipeFeatureDisabled()
         super(OCIRecipe, self).__init__()
@@ -182,6 +188,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         self.allow_internet = allow_internet
         self.build_args = build_args or {}
         self.build_path = build_path
+        self.image_name = image_name
 
     def __repr__(self):
         return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
@@ -463,10 +470,18 @@ class OCIRecipe(Storm, WebhookTargetMixin):
 
     @property
     def push_rules(self):
+        # if we're in a distribution that has credentials set at that level
+        # create a push rule using those credentials
+        if self.use_distribution_credentials:
+            push_rule = OCIDistributionPushRule(
+                self,
+                self.oci_project.distribution.oci_registry_credentials,
+                self.image_name)
+            return [push_rule]
         rules = IStore(self).find(
             OCIPushRule,
             OCIPushRule.recipe == self.id)
-        return rules
+        return list(rules)
 
     @property
     def _pending_states(self):
@@ -527,7 +542,20 @@ class OCIRecipe(Storm, WebhookTargetMixin):
 
     @property
     def can_upload_to_registry(self):
-        return not self.push_rules.is_empty()
+        return bool(self.push_rules)
+
+    @property
+    def use_distribution_credentials(self):
+        distribution = self.oci_project.distribution
+        return distribution and distribution.oci_registry_credentials
+
+    @property
+    def image_name(self):
+        return self._image_name or self.name
+
+    @image_name.setter
+    def image_name(self, value):
+        self._image_name = value
 
     def newPushRule(self, registrant, registry_url, image_name, credentials,
                     credentials_owner=None):
@@ -569,7 +597,8 @@ class OCIRecipeSet:
     def new(self, name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
             build_daily=False, processors=None, date_created=DEFAULT,
-            allow_internet=True, build_args=None, build_path=None):
+            allow_internet=True, build_args=None, build_path=None,
+            image_name=None):
         """See `IOCIRecipeSet`."""
         if not registrant.inTeam(owner):
             if owner.is_team:
@@ -594,7 +623,7 @@ class OCIRecipeSet:
         oci_recipe = OCIRecipe(
             name, registrant, owner, oci_project, git_ref, description,
             official, require_virtualized, build_file, build_daily,
-            date_created, allow_internet, build_args, build_path)
+            date_created, allow_internet, build_args, build_path, image_name)
         store.add(oci_recipe)
 
         if processors is None:
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index 9a9aefc..662c742 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -81,6 +81,12 @@
           <pre tal:content="build_args" />
         </dd>
       </dl>
+      <dl id="image-name" tal:condition="context/use_distribution_credentials">
+        <dt>Registry image name</dt>
+        <dd>
+          <span tal:content="context/image_name" />
+        </dd>
+      </dl>
     </div>
 
     <h2>Latest builds</h2>
@@ -144,35 +150,42 @@
     </div>
 
 
-    <h2>Recipe push rules</h2>
-    <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
-           style="margin-bottom: 1em; ">
-      <thead>
-        <tr>
-          <th>Registry URL</th>
-          <th>Username</th>
-          <th>Image Name</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tal:recipe-push-rules repeat="item view/push_rules">
-          <tr tal:define="rule item;
-                          show_credentials rule/registry_credentials/required:launchpad.View"
-              tal:attributes="id string:rule-${rule/id}">
-            <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
-            <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
-            <td tal:content="rule/image_name"/>
+    <div tal:condition=context/use_distribution_credentials>
+      <h3>Registry upload</h3>
+      <p>This recipe will use the registry credentials set by the parent distribution</p>
+    </div>
+
+    <div tal:condition="not: context/use_distribution_credentials">
+      <h2>Recipe push rules</h2>
+      <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
+            style="margin-bottom: 1em; ">
+        <thead>
+          <tr>
+            <th>Registry URL</th>
+            <th>Username</th>
+            <th>Image Name</th>
           </tr>
-        </tal:recipe-push-rules>
-      </tbody>
-    </table>
-    <p tal:condition="not: view/has_push_rules">
-      This OCI recipe has no push rules defined yet.
-    </p>
+        </thead>
+        <tbody>
+          <tal:recipe-push-rules repeat="item view/push_rules">
+            <tr tal:define="rule item;
+                            show_credentials rule/registry_credentials/required:launchpad.View"
+                tal:attributes="id string:rule-${rule/id}">
+              <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
+              <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
+              <td tal:content="rule/image_name"/>
+            </tr>
+          </tal:recipe-push-rules>
+        </tbody>
+      </table>
+      <p tal:condition="not: view/has_push_rules">
+        This OCI recipe has no push rules defined yet.
+      </p>
 
-    <div tal:define="link context/menu:context/edit_push_rules"
-         tal:condition="link/enabled">
-      <tal:edit-push-rules replace="structure link/fmt:link"/>
+      <div tal:define="link context/menu:context/edit_push_rules"
+          tal:condition="link/enabled">
+        <tal:edit-push-rules replace="structure link/fmt:link"/>
+      </div>
     </div>
 
   </div>
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
index 1cae71e..00d9b08 100644
--- a/lib/lp/oci/templates/ocirecipe-new.pt
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -41,6 +41,11 @@
         <tal:widget define="widget nocall:view/widgets/processors">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
+        <span tal:condition="view/use_distribution_credentials">
+          <tal:widget define="widget nocall:view/widgets/image_name" >
+            <metal:block use-macro="context/@@launchpad_form/widget_row" />
+          </tal:widget>
+        </span>
       </table>
     </metal:formbody>
   </div>
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index a0b1c59..699954a 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -749,6 +749,36 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
             "VAR3": "A string",
         }, recipe.build_args)
 
+    def test_use_distribution_credentials_set(self):
+        distribution = self.factory.makeDistribution()
+        credentials = self.factory.makeOCIRegistryCredentials()
+        with person_logged_in(distribution.owner):
+            distribution.oci_registry_credentials = credentials
+        project = self.factory.makeOCIProject(pillar=distribution)
+        recipe = self.factory.makeOCIRecipe(oci_project=project)
+        self.assertTrue(recipe.use_distribution_credentials)
+
+    def test_use_distribution_credentials_not_set(self):
+        distribution = self.factory.makeDistribution()
+        project = self.factory.makeOCIProject(pillar=distribution)
+        recipe = self.factory.makeOCIRecipe(oci_project=project)
+        self.assertFalse(recipe.use_distribution_credentials)
+
+    def test_image_name_set(self):
+        distribution = self.factory.makeDistribution()
+        project = self.factory.makeOCIProject(pillar=distribution)
+        recipe = self.factory.makeOCIRecipe(oci_project=project)
+        image_name = self.factory.getUniqueUnicode()
+        with person_logged_in(recipe.owner):
+            recipe.image_name = image_name
+        self.assertEqual(image_name, removeSecurityProxy(recipe)._image_name)
+
+    def test_image_name_not_set(self):
+        distribution = self.factory.makeDistribution()
+        project = self.factory.makeOCIProject(pillar=distribution)
+        recipe = self.factory.makeOCIRecipe(oci_project=project)
+        self.assertEqual(recipe.name, recipe.image_name)
+
 
 class TestOCIRecipeProcessors(TestCaseWithFactory):
 
@@ -1284,6 +1314,29 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
         self.assertEqual(
             image_name, push_rules["entries"][0]["image_name"])
 
+    def test_api_set_image_name(self):
+        """Can you set and retrieve the image name via the API?"""
+        self.setConfig()
+
+        image_name = self.factory.getUniqueUnicode()
+
+        with person_logged_in(self.person):
+            oci_project = self.factory.makeOCIProject(
+                registrant=self.person)
+            recipe = self.factory.makeOCIRecipe(
+                oci_project=oci_project, owner=self.person,
+                registrant=self.person)
+            url = api_url(recipe)
+
+        resp = self.webservice.patch(
+            url, 'application/json',
+            json.dumps({'image_name': image_name}))
+
+        self.assertEqual(209, resp.status, resp.body)
+
+        ws_project = self.load_from_api(url)
+        self.assertEqual(image_name, ws_project['image_name'])
+
 
 class TestOCIRecipeAsyncWebservice(TestCaseWithFactory):
     layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index ecfaee3..403662d 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -69,6 +69,7 @@ from lp.services.compat import mock
 from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     admin_logged_in,
+    person_logged_in,
     TestCaseWithFactory,
     )
 from lp.testing.fixture import ZopeUtilityFixture
@@ -226,6 +227,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
         }))
 
     @responses.activate
+<<<<<<< lib/lp/oci/tests/test_ociregistryclient.py
     def test_upload_ignores_superseded_builds(self):
         self.build.updateStatus(BuildStatus.FULLYBUILT)
         recipe = self.build.recipe
@@ -249,6 +251,56 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
             OCIRecipeBuildRegistryUploadStatus.SUPERSEDED,
             self.build.registry_upload_status)
         self.assertEqual(0, len(responses.calls))
+=======
+    def test_upload_with_distribution_credentials(self):
+        self._makeFiles()
+        self.useFixture(MockPatch(
+            "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
+        self.useFixture(MockPatch(
+            "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
+            return_value=999))
+        credentials = self.factory.makeOCIRegistryCredentials()
+        image_name = self.factory.getUniqueUnicode()
+        self.build.recipe.image_name = image_name
+        distro = self.build.recipe.oci_project.distribution
+        with person_logged_in(distro.owner):
+            distro.oci_registry_credentials = credentials
+        # we have distribution credentials, we should have a 'push rule'
+        push_rule = self.build.recipe.push_rules[0]
+        responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
+        self.addManifestResponses(push_rule)
+
+        self.client.upload(self.build)
+
+        request = json.loads(responses.calls[1].request.body)
+
+        self.assertThat(request, MatchesDict({
+            "layers": MatchesListwise([
+                MatchesDict({
+                    "mediaType": Equals(
+                        "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+                    "digest": Equals("diff_id_1"),
+                    "size": Equals(999)}),
+                MatchesDict({
+                    "mediaType": Equals(
+                        "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+                    "digest": Equals("diff_id_2"),
+                    "size": Equals(999)})
+            ]),
+            "schemaVersion": Equals(2),
+            "config": MatchesDict({
+                "mediaType": Equals(
+                    "application/vnd.docker.container.image.v1+json"),
+                "digest": Equals(
+                    "sha256:33b69b4b6e106f9fc7a8b93409"
+                    "36c85cf7f84b2d017e7b55bee6ab214761f6ab"),
+                "size": Equals(52)
+            }),
+            "mediaType": Equals(
+                "application/vnd.docker.distribution.manifest.v2+json")
+        }))
+
+>>>>>>> lib/lp/oci/tests/test_ociregistryclient.py
 
     @responses.activate
     def test_upload_formats_credentials(self):