← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:oci-multi-arch-upload into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:oci-multi-arch-upload into launchpad:master with ~pappacena/launchpad:oci-unify-request-builds as a prerequisite.

Commit message:
Upload multi-arch OCI images

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/391783
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:oci-multi-arch-upload into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 8a9a31b..d5a23f1 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2710,6 +2710,7 @@ public.account                          = SELECT
 public.archive                          = SELECT
 public.branch                           = SELECT
 public.builder                          = SELECT
+public.builderprocessor                 = SELECT
 public.buildfarmjob                     = SELECT, INSERT
 public.buildqueue                       = SELECT, INSERT, UPDATE
 public.distribution                     = SELECT
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 0bd3cd2..131c4d3 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -178,6 +178,16 @@ class IOCIRecipeBuildRequest(Interface):
         title=_("If set, limit builds to these architecture tags."),
         value_type=TextLine(), required=False, readonly=True)
 
+    uploaded_manifests = Dict(
+        title=_("A dict of manifest information per build."),
+        key_type=Int(), value_type=Dict(),
+        required=False, readonly=True)
+
+    def addUploadedManifest(build_id, manifest_info):
+        """Add the manifest information for one of the builds in this
+        BuildRequest.
+        """
+
 
 class IOCIRecipeView(Interface):
     """`IOCIRecipe` attributes that require launchpad.View permission."""
diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py
index 104a2ff..2b59faf 100644
--- a/lib/lp/oci/interfaces/ocirecipejob.py
+++ b/lib/lp/oci/interfaces/ocirecipejob.py
@@ -19,6 +19,8 @@ from zope.interface import (
     )
 from zope.schema import (
     Datetime,
+    Dict,
+    Int,
     List,
     TextLine,
     )
@@ -78,6 +80,16 @@ class IOCIRecipeRequestBuildsJob(IRunnableJob):
     error_message = TextLine(
         title=_("Error message"), required=False, readonly=True)
 
+    uploaded_manifests = Dict(
+        title=_("A dict of manifest information per build."),
+        key_type=Int(), value_type=Dict(),
+        required=False, readonly=True)
+
+    def addUploadedManifest(build_id, manifest_info):
+        """Add the manifest information for one of the builds in this
+        BuildRequest.
+        """
+
 
 class IOCIRecipeRequestBuildsJobSource(IJobSource):
 
diff --git a/lib/lp/oci/interfaces/ociregistryclient.py b/lib/lp/oci/interfaces/ociregistryclient.py
index 184a93f..71af23a 100644
--- a/lib/lp/oci/interfaces/ociregistryclient.py
+++ b/lib/lp/oci/interfaces/ociregistryclient.py
@@ -54,3 +54,6 @@ class IOCIRegistryClient(Interface):
 
         :param build: The `IOCIRecipeBuild` to upload.
         """
+
+    def uploadManifestList(build_request):
+        """Upload the "fat manifest" which aggregates all platforms built."""
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 15fa5be..e4b46e3 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -38,7 +38,10 @@ from zope.component import (
 from zope.event import notify
 from zope.interface import implementer
 from zope.security.interfaces import Unauthorized
-from zope.security.proxy import removeSecurityProxy
+from zope.security.proxy import (
+    isinstance as zope_isinstance,
+    removeSecurityProxy,
+    )
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.app.interfaces.security import IAuthorization
@@ -695,6 +698,13 @@ class OCIRecipeBuildRequest:
         return self.job.date_finished
 
     @property
+    def uploaded_manifests(self):
+        return self.job.uploaded_manifests
+
+    def addUploadedManifest(self, build_id, manifest_info):
+        self.job.addUploadedManifest(build_id, manifest_info)
+
+    @property
     def status(self):
         status_map = {
             JobStatus.WAITING: OCIRecipeBuildRequestStatus.PENDING,
@@ -712,3 +722,8 @@ class OCIRecipeBuildRequest:
     @property
     def builds(self):
         return self.job.builds
+
+    def __eq__(self, other):
+        if not zope_isinstance(other, self.__class__):
+            return False
+        return self.id == other.id
diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py
index 73fa4d6..0118d48 100644
--- a/lib/lp/oci/model/ocirecipebuildjob.py
+++ b/lib/lp/oci/model/ocirecipebuildjob.py
@@ -41,8 +41,12 @@ from lp.oci.interfaces.ociregistryclient import (
     OCIRegistryError,
     )
 from lp.services.database.enumcol import DBEnum
-from lp.services.database.interfaces import IStore
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
 from lp.services.database.stormbase import StormBase
+from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import (
     EnumeratedSubclass,
     Job,
@@ -227,6 +231,37 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
         """See `IOCIRegistryUploadJob`."""
         self.json_data["errors"] = errors
 
+    def allBuildsUploaded(self, build_request):
+        """Returns True if all builds of the given build_request already
+        finished uploading. False otherwise.
+
+        Note that this method locks all upload jobs at database level,
+        preventing them from updating their status until the end of the
+        current transaction. Use it with caution.
+        """
+        builds = list(build_request.builds)
+        uploads_per_build = {i: list(i.registry_upload_jobs) for i in builds}
+        upload_jobs = sum(uploads_per_build.values(), [])
+
+        # Lock the Job rows, so no other job updates it's status until the
+        # end of this job's transaction. This is done to avoid race conditions,
+        # where 2 upload jobs could be running simultaneously and none of them
+        # realises that is the last upload.
+        store = IMasterStore(builds[0])
+        placeholders = ', '.join('?' for _ in upload_jobs)
+        sql = (
+            "SELECT id, status FROM job WHERE id IN (%s) FOR UPDATE"
+            % placeholders)
+        store.execute(sql, [i.job_id for i in upload_jobs])
+
+        for build, upload_jobs in uploads_per_build.items():
+            has_finished_upload = any(
+                i.status == JobStatus.COMPLETED or i.job_id == self.job_id
+                for i in upload_jobs)
+            if not has_finished_upload:
+                return False
+        return True
+
     def run(self):
         """See `IRunnableJob`."""
         client = getUtility(IOCIRegistryClient)
@@ -235,6 +270,12 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
         try:
             try:
                 client.upload(self.build)
+                # The "allBuildsUploaded" call will lock, on the database,
+                # all upload jobs for update until this transaction finishes.
+                # So, make sure this is the last thing being done by this job.
+                build_request = self.build.build_request
+                if build_request and self.allBuildsUploaded(build_request):
+                    client.uploadManifestList(self.build.build_request)
             except OCIRegistryError as e:
                 self.error_summary = str(e)
                 self.errors = e.errors
diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py
index 15eac4f..3320cff 100644
--- a/lib/lp/oci/model/ocirecipejob.py
+++ b/lib/lp/oci/model/ocirecipejob.py
@@ -169,6 +169,8 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
             "requester": requester.id,
             "architectures": (
                 list(architectures) if architectures is not None else None),
+            # A dict of build_id: manifest location
+            "uploaded_manifests": {}
         }
         recipe_job = OCIRecipeJob(recipe, cls.class_job_type, metadata)
         job = cls(recipe_job)
@@ -258,6 +260,16 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
         architectures = self.metadata["architectures"]
         return set(architectures) if architectures is not None else None
 
+    @property
+    def uploaded_manifests(self):
+        return {
+            # Converts keys to integer since saving json to database
+            # converts them to strings.
+            int(k): v for k, v in self.metadata["uploaded_manifests"].items()}
+
+    def addUploadedManifest(self, build_id, manifest_info):
+        self.metadata["uploaded_manifests"][int(build_id)] = manifest_info
+
     def run(self):
         """See `IRunnableJob`."""
         requester = self.requester
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 7561267..55f61ce 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -15,6 +15,8 @@ from functools import partial
 import hashlib
 from io import BytesIO
 import json
+
+
 try:
     from json.decoder import JSONDecodeError
 except ImportError:
@@ -229,17 +231,59 @@ class OCIRegistryClient:
         """
         # XXX twom 2020-04-17 This needs to include OCIProjectSeries and
         # base image name
-
         return "{}".format("edge")
 
     @classmethod
+    def _uploadRegistryManifest(cls, http_client, registry_manifest,
+                                push_rule, build=None):
+        """Uploads the build manifest, returning its content information.
+
+        The returned information can be used to create a Manifest list
+        including the uploaded manifest, for example, in order to create
+        multi-architecture images.
+
+        :return: A dict with {"digest": "sha256:xxx", "size": total_bytes}
+        """
+        digest = None
+        data = json.dumps(registry_manifest)
+        size = len(data)
+        content_type = registry_manifest.get(
+            "mediaType",
+            "application/vnd.docker.distribution.manifest.v2+json")
+        if build is None:
+            # When uploading a manifest list, use the tag.
+            tag = cls._calculateTag(build, push_rule)
+        else:
+            # When uploading individual build manifests, use their digest.
+            tag = "sha256:%s" % hashlib.sha256(data).hexdigest()
+        try:
+            manifest_response = http_client.requestPath(
+                "/manifests/{}".format(tag),
+                data=data,
+                headers={"Content-Type": content_type},
+                method="PUT")
+            digest = manifest_response.headers.get("Docker-Content-Digest")
+        except HTTPError as http_error:
+            manifest_response = http_error.response
+        if manifest_response.status_code != 201:
+            if build:
+                msg = "Failed to upload manifest for {} ({}) in {}".format(
+                    build.recipe.name, push_rule.registry_url, build.id)
+            else:
+                msg = ("Failed to upload manifest of manifests for"
+                       " {} ({})").format(
+                    push_rule.recipe.name, push_rule.registry_url)
+            raise cls._makeRegistryError(
+                ManifestUploadFailed, msg, manifest_response)
+        return {"digest": digest, "size": size}
+
+    @classmethod
     def _upload_to_push_rule(
             cls, push_rule, build, manifest, digests, preloaded_data):
         http_client = RegistryHTTPClient.getInstance(push_rule)
 
         for section in manifest:
-            # Work out names and tags
-            tag = cls._calculateTag(build, push_rule)
+            # Work out names
             file_data = preloaded_data[section["Config"]]
             config = file_data["config_file"]
             #  Upload the layers involved
@@ -269,24 +313,13 @@ class OCIRegistryClient:
                 layer_sizes)
 
             # Upload the registry manifest
-            try:
-                manifest_response = http_client.requestPath(
-                    "/manifests/{}".format(tag),
-                    json=registry_manifest,
-                    headers={
-                        "Content-Type":
-                            "application/"
-                            "vnd.docker.distribution.manifest.v2+json"
-                    },
-                    method="PUT")
-            except HTTPError as http_error:
-                manifest_response = http_error.response
-            if manifest_response.status_code != 201:
-                raise cls._makeRegistryError(
-                    ManifestUploadFailed,
-                    "Failed to upload manifest for {} ({}) in {}".format(
-                        build.recipe.name, push_rule.registry_url, build.id),
-                    manifest_response)
+            maanifest = cls._uploadRegistryManifest(
+                http_client, registry_manifest, push_rule, build)
+
+            # Save the uploaded manifest location, so we can use it in case
+            # this is a multi-arch image upload.
+            if build.build_request:
+                build.build_request.addUploadedManifest(build.id, maanifest)
 
     @classmethod
     def upload(cls, build):
@@ -318,6 +351,43 @@ class OCIRegistryClient:
         elif len(exceptions) > 1:
             raise MultipleOCIRegistryError(exceptions)
 
+    @classmethod
+    def makeMultiArchManifest(cls, build_request):
+        """Returns the multi-arch manifest content including all builds of
+        the given build_request.
+        """
+        manifests = []
+        for build in build_request.builds:
+            build_manifest = build_request.uploaded_manifests.get(build.id)
+            if not build_manifest:
+                continue
+            digest = build_manifest["digest"]
+            size = build_manifest["size"]
+            arch = build.processor.name
+            manifests.append({
+                "mediaType": ("application/"
+                              "vnd.docker.distribution.manifest.v2+json"),
+                "size": size,
+                "digest": digest,
+                "platform": {"architecture": arch, "os": "linux"}
+            })
+        return {
+          "schemaVersion": 2,
+          "mediaType": ("application/"
+                        "vnd.docker.distribution.manifest.list.v2+json"),
+          "manifests": manifests}
+
+    @classmethod
+    def uploadManifestList(cls, build_request):
+        """Uploads to all build_request.recipe.push_rules the manifest list
+        for the builds in the given build_request.
+        """
+        multi_manifest_content = cls.makeMultiArchManifest(build_request)
+        for push_rule in build_request.recipe.push_rules:
+            http_client = RegistryHTTPClient.getInstance(push_rule)
+            cls._uploadRegistryManifest(
+                http_client, multi_manifest_content, push_rule, build=None)
+
 
 class OCIRegistryAuthenticationError(Exception):
     def __init__(self, msg, http_error=None):
@@ -436,8 +506,8 @@ class BearerTokenRegistryClient(RegistryHTTPClient):
 
         :param auth_retry: Should we authenticate and retry the request if
                            it fails with HTTP 401 code?"""
+        headers = request_kwargs.pop("headers", {})
         try:
-            headers = request_kwargs.pop("headers", {})
             if self.auth_token is not None:
                 headers["Authorization"] = "Bearer %s" % self.auth_token
             return proxy_urlfetch(url, headers=headers, **request_kwargs)
@@ -445,5 +515,6 @@ class BearerTokenRegistryClient(RegistryHTTPClient):
             if auth_retry and e.response.status_code == 401:
                 self.authenticate(e.response)
                 return self.request(
-                    url, auth_retry=False, *args, **request_kwargs)
+                    url, auth_retry=False, headers=headers,
+                    *args, **request_kwargs)
             raise
diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py
index fd3e0ff..f497c03 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildjob.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py
@@ -14,11 +14,14 @@ from testtools.matchers import (
     MatchesDict,
     MatchesListwise,
     MatchesStructure,
+    Not,
     )
 import transaction
+from zope.component import getUtility
 from zope.interface import implementer
 
 from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -27,6 +30,7 @@ from lp.oci.interfaces.ocirecipebuildjob import (
     IOCIRecipeBuildJob,
     IOCIRegistryUploadJob,
     )
+from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
 from lp.oci.interfaces.ociregistryclient import (
     BlobUploadFailed,
     IOCIRegistryClient,
@@ -42,7 +46,10 @@ from lp.services.features.testing import FeatureFixture
 from lp.services.job.runner import JobRunner
 from lp.services.webapp import canonical_url
 from lp.services.webhooks.testing import LogsScheduledWebhooks
-from lp.testing import TestCaseWithFactory
+from lp.testing import (
+    admin_logged_in,
+    TestCaseWithFactory,
+    )
 from lp.testing.dbuser import dbuser
 from lp.testing.fakemethod import FakeMethod
 from lp.testing.fixture import ZopeUtilityFixture
@@ -69,6 +76,7 @@ class FakeRegistryClient:
 
     def __init__(self):
         self.upload = FakeMethod()
+        self.uploadManifestList = FakeMethod()
 
 
 class FakeOCIBuildJob(OCIRecipeBuildJobDerived):
@@ -122,10 +130,13 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory):
         ocibuild = self.factory.makeOCIRecipeBuild(
             builder=self.factory.makeBuilder(), **kwargs)
         ocibuild.updateStatus(BuildStatus.FULLYBUILT)
-        self.factory.makeWebhook(
-            target=ocibuild.recipe, event_types=["oci-recipe:build:0.1"])
+        self.makeWebhook(ocibuild.recipe)
         return ocibuild
 
+    def makeWebhook(self, recipe):
+        self.factory.makeWebhook(
+            target=recipe, event_types=["oci-recipe:build:0.1"])
+
     def assertWebhookDeliveries(self, ocibuild,
                                 expected_registry_upload_statuses, logger):
         hook = ocibuild.recipe.webhooks.one()
@@ -137,7 +148,8 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory):
             "action": Equals("status-changed"),
             "recipe": Equals(
                 canonical_url(ocibuild.recipe, force_local_path=True)),
-            "build_request": Is(None),
+            "build_request": (Is(None) if ocibuild.build_request is None
+                              else Not(Is(None))),
             "status": Equals("Successfully built"),
             "registry_upload_status": Equals(expected),
             } for expected in expected_registry_upload_statuses]
@@ -165,22 +177,94 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory):
         job = OCIRegistryUploadJob.create(ocibuild)
         self.assertProvides(job, IOCIRegistryUploadJob)
 
+    def makeRecipe(self, include_i386=True, include_amd64=True):
+        i386 = getUtility(IProcessorSet).getByName("386")
+        amd64 = getUtility(IProcessorSet).getByName("amd64")
+        recipe = self.factory.makeOCIRecipe()
+        distroseries = self.factory.makeDistroSeries(
+            distribution=recipe.oci_project.distribution)
+        distro_i386 = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag="i386",
+            processor=i386)
+        distro_i386.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+        distro_amd64 = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag="amd64",
+            processor=amd64)
+        distro_amd64.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+
+        archs = []
+        if include_i386:
+            archs.append(i386)
+        if include_amd64:
+            archs.append(amd64)
+        recipe.setProcessors(archs)
+        return recipe
+
     def test_run(self):
         logger = self.useFixture(FakeLogger())
-        ocibuild = self.makeOCIRecipeBuild()
+        recipe = self.makeRecipe(include_i386=False, include_amd64=True)
+        # Creates a build request with a build in it.
+        build_request = recipe.requestBuilds(recipe.owner)
+        with admin_logged_in():
+            jobs = getUtility(IOCIRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser):
+                JobRunner(jobs).runAll()
+
+        self.assertEqual(1, build_request.builds.count())
+        ocibuild = build_request.builds[0]
+        ocibuild.updateStatus(BuildStatus.FULLYBUILT)
+        self.makeWebhook(recipe)
+
         self.assertContentEqual([], ocibuild.registry_upload_jobs)
         job = OCIRegistryUploadJob.create(ocibuild)
         client = FakeRegistryClient()
         self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
         with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
             run_isolated_jobs([job])
+
         self.assertEqual([((ocibuild,), {})], client.upload.calls)
+        self.assertEqual([((build_request,), {})],
+                         client.uploadManifestList.calls)
         self.assertContentEqual([job], ocibuild.registry_upload_jobs)
         self.assertIsNone(job.error_summary)
         self.assertIsNone(job.errors)
         self.assertEqual([], pop_notifications())
         self.assertWebhookDeliveries(ocibuild, ["Pending", "Uploaded"], logger)
 
+    def test_run_multiple_architectures(self):
+        recipe = self.makeRecipe()
+
+        # Creates a build request with builds.
+        build_request = recipe.requestBuilds(recipe.owner)
+        with admin_logged_in():
+            jobs = getUtility(IOCIRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser):
+                JobRunner(jobs).runAll()
+
+        builds = build_request.builds
+        self.assertEqual(2, builds.count())
+        self.assertEqual(builds[0].build_request, builds[1].build_request)
+
+        upload_jobs = []
+        for build in builds:
+            self.assertContentEqual([], build.registry_upload_jobs)
+            upload_jobs.append(OCIRegistryUploadJob.create(build))
+
+        client = FakeRegistryClient()
+        self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
+
+        with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
+            JobRunner([upload_jobs[0]]).runAll()
+        self.assertEqual([((builds[0],), {})], client.upload.calls)
+        self.assertEqual([], client.uploadManifestList.calls)
+
+        with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
+            JobRunner([upload_jobs[1]]).runAll()
+        self.assertEqual(
+            [((builds[0],), {}), ((builds[1],), {})], client.upload.calls)
+        self.assertEqual([((builds[1].build_request, ), {})],
+                         client.uploadManifestList.calls)
+
     def test_run_failed_registry_error(self):
         # A run that fails with a registry error sets the registry upload
         # status to FAILED, and stores the detailed errors.
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index 3c5aa8c..c73cb0c 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -11,6 +11,7 @@ from functools import partial
 import io
 import json
 import os
+import re
 import tarfile
 import uuid
 
@@ -33,13 +34,17 @@ from testtools.matchers import (
     Raises,
     )
 import transaction
+from zope.component._api import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
 from lp.oci.interfaces.ociregistryclient import (
     BlobUploadFailed,
     ManifestUploadFailed,
     MultipleOCIRegistryError,
     )
+from lp.oci.model.ocirecipe import OCIRecipeBuildRequest
 from lp.oci.model.ociregistryclient import (
     BearerTokenRegistryClient,
     OCIRegistryAuthenticationError,
@@ -50,6 +55,7 @@ from lp.oci.model.ociregistryclient import (
 from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.services.compat import mock
 from lp.testing import TestCaseWithFactory
+from lp.testing.fixture import ZopeUtilityFixture
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
@@ -142,6 +148,23 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
 
         transaction.commit()
 
+    def addManifestResponses(self, push_rule, status_code=201, json=None):
+        """Add responses for manifest upload URLs."""
+        # PUT to "anonymous" architecture-specific manifest.
+        manifests_url = "{}/v2/{}/manifests/sha256:.*".format(
+            push_rule.registry_credentials.url,
+            push_rule.image_name
+        )
+        responses.add(
+            "PUT", re.compile(manifests_url), status=status_code, json=json)
+
+        # PUT to tagged multi-arch manifest.
+        manifests_url = "{}/v2/{}/manifests/edge".format(
+            push_rule.registry_credentials.url,
+            push_rule.image_name
+        )
+        responses.add("PUT", manifests_url, status=status_code, json=json)
+
     @responses.activate
     def test_upload(self):
         self._makeFiles()
@@ -154,11 +177,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
         push_rule = self.build.recipe.push_rules[0]
         responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
 
-        manifests_url = "{}/v2/{}/manifests/edge".format(
-            push_rule.registry_credentials.url,
-            push_rule.image_name
-        )
-        responses.add("PUT", manifests_url, status=201)
+        self.addManifestResponses(push_rule)
 
         self.client.upload(self.build)
 
@@ -206,9 +225,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
         push_rule = self.build.recipe.push_rules[0]
         responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
 
-        manifests_url = "{}/v2/{}/manifests/edge".format(
-            push_rule.registry_credentials.url, push_rule.image_name)
-        responses.add("PUT", manifests_url, status=201)
+        self.addManifestResponses(push_rule)
 
         self.client.upload(self.build)
 
@@ -237,10 +254,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
             responses.add(
                 "GET", "%s/v2/" % push_rule.registry_url, status=200)
 
-            manifests_url = "{}/v2/{}/manifests/edge".format(
-                push_rule.registry_credentials.url, push_rule.image_name)
             status = 400 if i < 2 else 201
-            responses.add("PUT", manifests_url, status=status)
+            self.addManifestResponses(push_rule, status_code=status)
 
         error = self.assertRaises(
             MultipleOCIRegistryError, self.client.upload, self.build)
@@ -472,9 +487,6 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
         responses.add(
             "GET", "{}/v2/".format(push_rule.registry_url), status=200)
 
-        manifests_url = "{}/v2/{}/manifests/edge".format(
-            push_rule.registry_credentials.url,
-            push_rule.image_name)
         put_errors = [
             {
                 "code": "MANIFEST_INVALID",
@@ -482,8 +494,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
                 "detail": [],
                 },
             ]
-        responses.add(
-            "PUT", manifests_url, status=400, json={"errors": put_errors})
+        self.addManifestResponses(
+            push_rule, status_code=400, json={"errors": put_errors})
 
         expected_msg = "Failed to upload manifest for {} ({}) in {}".format(
             self.build.recipe.name, self.push_rule.registry_url, self.build.id)
@@ -509,10 +521,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
         responses.add(
             "GET", "{}/v2/".format(push_rule.registry_url), status=200)
 
-        manifests_url = "{}/v2/{}/manifests/edge".format(
-            push_rule.registry_credentials.url,
-            push_rule.image_name)
-        responses.add("PUT", manifests_url, status=200)
+        self.addManifestResponses(push_rule, status_code=200)
 
         expected_msg = "Failed to upload manifest for {} ({}) in {}".format(
             self.build.recipe.name, self.push_rule.registry_url, self.build.id)
@@ -526,6 +535,64 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
                         Equals(expected_msg)),
                     MatchesStructure(errors=Is(None))))))
 
+    @responses.activate
+    def test_multi_arch_manifest_upload(self):
+        """Ensure that multi-arch manifest upload works and tags correctly
+        the uploaded image."""
+        # Creates a build request with 2 builds.
+        recipe = self.build.recipe
+        build1 = self.build
+        build2 = self.factory.makeOCIRecipeBuild(
+            recipe=recipe)
+        naked_build1 = removeSecurityProxy(build1)
+        naked_build2 = removeSecurityProxy(build1)
+        naked_build1.processor = getUtility(IProcessorSet).getByName('386')
+        naked_build2.processor = getUtility(IProcessorSet).getByName('amd64')
+
+        # Creates a mock IOCIRecipeRequestBuildsJobSource, as it was created
+        # by the celery job and triggered the 2 registry uploads already.
+        job = mock.Mock()
+        job.builds = [build1, build2]
+        job.uploaded_manifests = {
+            build1.id: {"digest": "build1digest", "size": 123},
+            build2.id: {"digest": "build2digest", "size": 321},
+        }
+        job_source = mock.Mock()
+        job_source.getByOCIRecipeAndID.return_value = job
+        self.useFixture(
+            ZopeUtilityFixture(job_source, IOCIRecipeRequestBuildsJobSource))
+        build_request = OCIRecipeBuildRequest(recipe, -1)
+
+        push_rule = self.build.recipe.push_rules[0]
+        responses.add(
+            "GET", "{}/v2/".format(push_rule.registry_url), status=200)
+        self.addManifestResponses(push_rule, status_code=201)
+
+        self.client.uploadManifestList(build_request)
+        self.assertEqual(2, len(responses.calls))
+        auth_call, manifest_call = responses.calls
+        self.assertEndsWith(
+            manifest_call.request.url,
+            "/v2/%s/manifests/edge" % push_rule.image_name)
+        self.assertEqual({
+            "schemaVersion": 2,
+            "mediaType": "application/"
+                         "vnd.docker.distribution.manifest.list.v2+json",
+            "manifests": [{
+                "platform": {"os": "linux", "architecture": "amd64"},
+                "mediaType": "application/"
+                             "vnd.docker.distribution.manifest.v2+json",
+                "digest": "build1digest",
+                "size": 123
+            }, {
+                "platform": {"os": "linux", "architecture": "386"},
+                "mediaType": "application/"
+                             "vnd.docker.distribution.manifest.v2+json",
+                "digest": "build2digest",
+                "size": 321
+            }]
+        }, json.loads(manifest_call.request.body))
+
 
 class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
                              TestCaseWithFactory):