← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Upload built images to an OCI registry

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/381981
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-registry-upload into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 942c5b4..55226e1 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -999,6 +999,7 @@ public.livefsfile                             = SELECT
 public.ocifile                                = SELECT
 public.ociproject                             = SELECT
 public.ociprojectname                         = SELECT
+public.ocipushrule                            = SELECT
 public.ocirecipe                              = SELECT
 public.ocirecipebuild                         = SELECT, UPDATE
 public.ocirecipebuildjob                      = SELECT, INSERT
@@ -1439,6 +1440,7 @@ public.ocifile                          = SELECT, INSERT
 public.ociproject                       = SELECT
 public.ociprojectname                   = SELECT
 public.ociprojectseries                 = SELECT
+public.ocipushrule                      = SELECT
 public.ocirecipe                        = SELECT, UPDATE
 public.ocirecipebuild                   = SELECT, UPDATE
 public.ocirecipebuildjob                = SELECT, INSERT, UPDATE
@@ -2678,3 +2680,42 @@ public.teammembership                   = SELECT
 public.teamparticipation                = SELECT
 public.webhook                          = SELECT
 public.webhookjob                       = SELECT, INSERT
+
+[oci-build-job]
+type=user
+groups=script
+public.account                          = SELECT
+public.archive                          = SELECT
+public.branch                           = SELECT
+public.builder                          = SELECT
+public.buildfarmjob                     = SELECT, INSERT
+public.buildqueue                       = SELECT, INSERT, UPDATE
+public.distribution                     = SELECT
+public.distroarchseries                 = SELECT
+public.distroseries                     = SELECT
+public.emailaddress                     = SELECT
+public.gitref                           = SELECT
+public.gitrepository                    = SELECT
+public.job                              = SELECT, INSERT, UPDATE
+public.libraryfilealias                 = SELECT
+public.libraryfilecontent               = SELECT
+public.person                           = SELECT
+public.personsettings                   = SELECT
+public.pocketchroot                     = SELECT
+public.processor                        = SELECT
+public.product                          = SELECT
+public.ocirecipe                        = SELECT, UPDATE
+public.ocirecipearch                    = SELECT
+public.ocirecipebuild                   = SELECT, INSERT, UPDATE
+public.ocirecipebuildjob                = SELECT, UPDATE
+public.ocifile                          = SELECT
+public.ociproject                       = SELECT
+public.ociprojectname                   = SELECT
+public.ociprojectseries                 = SELECT
+public.ocipushrule                      = SELECT
+public.ociregistrycredentials           = SELECT
+public.sourcepackagename                = SELECT
+public.teammembership                   = SELECT
+public.teamparticipation                = SELECT
+public.webhook                          = SELECT
+public.webhookjob                       = SELECT, INSERT
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 565412d..7c2ead7 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -127,4 +127,22 @@
             interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
     </securedutility>
 
+    <!-- OCI related jobs -->
+    <securedutility
+        component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
+        provides="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJobSource">
+        <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJobSource" />
+    </securedutility>
+    <class class="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob">
+        <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRecipeBuildJob" />
+        <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJob" />
+    </class>
+
+    <!-- Registry interaction -->
+    <securedutility
+        class="lp.oci.model.ociregistryclient.OCIRegistryClient"
+        provides="lp.oci.interfaces.ociregistryclient.IOCIRegistryClient">
+        <allow interface="lp.oci.interfaces.ociregistryclient.IOCIRegistryClient" />
+    </securedutility>
+
 </configure>
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 25a8967..eda2782 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -214,6 +214,12 @@ class IOCIRecipeView(Interface):
         # Really IOCIPushRule, patched in _schema_cirular_imports.
         value_type=Reference(schema=Interface), readonly=True)
 
+    can_upload_to_registry = Bool(
+        title=_("Can upload to registry"), required=True, readonly=True,
+        description=_(
+            "Whether everything is set up to allow uploading builds of "
+            "this OCI recipe to a registry."))
+
 
 class IOCIRecipeEdit(IWebhookTarget):
     """`IOCIRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 752e61d..58968a5 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -12,8 +12,14 @@ __all__ = [
     'IOCIRecipeBuildSet',
     ]
 
-from lazr.restful.fields import Reference
-from zope.interface import Interface
+from lazr.restful.fields import (
+    CollectionField,
+    Reference,
+    )
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
 from zope.schema import (
     Bool,
     Datetime,
@@ -77,6 +83,7 @@ class IOCIRecipeBuildView(IPackageBuild):
         title=_("The series and architecture for which to build."),
         required=True, readonly=True)
 
+<<<<<<< lib/lp/oci/interfaces/ocirecipebuild.py
     score = Int(
         title=_("Score of the related build farm job (if any)."),
         required=False, readonly=True)
@@ -116,6 +123,17 @@ class IOCIRecipeBuildEdit(Interface):
 
         If the build is not in a cancellable state, this method is a no-op.
         """
+=======
+    manifest = Attribute(_("The manifest of the image."))
+
+    digests = Attribute(_("File containing the image digests."))
+
+    registry_upload_jobs = CollectionField(
+        title=_("Registry upload jobs for this build."),
+        # Really IOCIRegistryUploadJob.
+        value_type=Reference(schema=Interface),
+        readonly=True)
+>>>>>>> lib/lp/oci/interfaces/ocirecipebuild.py
 
 
 class IOCIRecipeBuildAdmin(Interface):
diff --git a/lib/lp/oci/interfaces/ocirecipebuildjob.py b/lib/lp/oci/interfaces/ocirecipebuildjob.py
index 6c25b73..1cde82e 100644
--- a/lib/lp/oci/interfaces/ocirecipebuildjob.py
+++ b/lib/lp/oci/interfaces/ocirecipebuildjob.py
@@ -8,17 +8,30 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 __all__ = [
     'IOCIRecipeBuildJob',
+    'IOCIRecipeBuildRegistryUploadStatusChangedEvent',
+    'IOCIRegistryUploadJob',
+    'IOCIRegistryUploadJobSource',
     ]
 
 from lazr.restful.fields import Reference
+from zope.component.interfaces import IObjectEvent
 from zope.interface import (
     Attribute,
     Interface,
     )
+from zope.schema import TextLine
 
 from lp import _
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
-from lp.services.job.interfaces.job import IJob
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+
+
+class IOCIRecipeBuildRegistryUploadStatusChangedEvent(IObjectEvent):
+    """The store upload status of an OCI recipe build changed."""
 
 
 class IOCIRecipeBuildJob(Interface):
@@ -32,3 +45,19 @@ class IOCIRecipeBuildJob(Interface):
         schema=IOCIRecipeBuild, required=True, readonly=True)
 
     json_data = Attribute(_("A dict of data about the job."))
+
+
+class IOCIRegistryUploadJob(IRunnableJob):
+    """A Job that uploads an OCI image to a registry."""
+
+    error_message = TextLine(
+        title=_("Error message"), required=False, readonly=True)
+
+
+class IOCIRegistryUploadJobSource(IJobSource):
+
+    def create(build):
+        """Upload an OCI image to a registry.
+
+        :param build: The OCI recipe build to upload.
+        """
diff --git a/lib/lp/oci/interfaces/ociregistryclient.py b/lib/lp/oci/interfaces/ociregistryclient.py
new file mode 100644
index 0000000..efcd50f
--- /dev/null
+++ b/lib/lp/oci/interfaces/ociregistryclient.py
@@ -0,0 +1,33 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface for communication with an OCI registry."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'BlobUploadFailed',
+    'IOCIRegistryClient',
+    'ManifestUploadFailed',
+]
+
+from zope.interface import Interface
+
+
+class BlobUploadFailed(Exception):
+    pass
+
+
+class ManifestUploadFailed(Exception):
+    pass
+
+
+class IOCIRegistryClient(Interface):
+    """Interface for the API provided by an OCI registry."""
+
+    def upload(ocibuild):
+        """Upload an OCI image to a registry.
+
+        :param ocibuild: The `IOCIRecipeBuild` to upload.
+        """
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 4e3273a..e69c290 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -377,6 +377,10 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         order_by = Desc(OCIRecipeBuild.id)
         return self._getBuilds(filter_term, order_by)
 
+    @property
+    def can_upload_to_registry(self):
+        return bool(self.push_rules.count())
+
 
 class OCIRecipeArch(Storm):
     """Link table to back `OCIRecipe.processors`."""
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 21c1cce..54034e2 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -44,6 +44,10 @@ from lp.oci.interfaces.ocirecipebuild import (
     IOCIRecipeBuild,
     IOCIRecipeBuildSet,
     )
+from lp.oci.model.ocirecipebuildjob import (
+    OCIRecipeBuildJob,
+    OCIRecipeBuildJobType,
+    )
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.model.person import Person
 from lp.services.config import config
@@ -55,6 +59,11 @@ from lp.services.database.interfaces import (
     IMasterStore,
     IStore,
     )
+<<<<<<< lib/lp/oci/model/ocirecipebuild.py
+=======
+from lp.services.features import getFeatureFlag
+from lp.services.job.model.job import Job
+>>>>>>> lib/lp/oci/model/ocirecipebuild.py
 from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
@@ -365,6 +374,21 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
                             and self.digests is not None)
         return layer_files_present and metadata_present
 
+    @property
+    def registry_upload_jobs(self):
+        jobs = Store.of(self).find(
+            OCIRecipeBuildJob,
+            OCIRecipeBuildJob.build == self,
+            OCIRecipeBuildJob.job_type == OCIRecipeBuildJobType.REGISTRY_UPLOAD
+        )
+        jobs.order_by(Desc(OCIRecipeBuildJob.job_id))
+
+        def preload_jobs(rows):
+            load_related(Job, rows, ["job_id"])
+
+        return DecoratedResultSet(
+            jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
+
 
 @implementer(IOCIRecipeBuildSet)
 class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py
index 7d8699c..a93f7fe 100644
--- a/lib/lp/oci/model/ocirecipebuildjob.py
+++ b/lib/lp/oci/model/ocirecipebuildjob.py
@@ -11,6 +11,7 @@ __all__ = [
     'OCIRecipeBuildJobType',
     ]
 
+
 from lazr.delegates import delegate_to
 from lazr.enum import (
     DBEnumeratedType,
@@ -21,10 +22,21 @@ from storm.locals import (
     Int,
     Reference,
     )
-from zope.interface import implementer
+from zope.component.interfaces import ObjectEvent
+from zope.event import notify
+from zope.interface import (
+    implementer,
+    provider,
+    )
 
 from lp.app.errors import NotFoundError
-from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob
+from lp.oci.interfaces.ocirecipebuildjob import (
+    IOCIRecipeBuildJob,
+    IOCIRecipeBuildRegistryUploadStatusChangedEvent,
+    IOCIRegistryUploadJob,
+    IOCIRegistryUploadJobSource,
+    )
+from lp.oci.model.ociregistryclient import OCIRegistryClient
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.interfaces import IStore
 from lp.services.database.stormbase import StormBase
@@ -33,14 +45,12 @@ from lp.services.job.model.job import (
     Job,
     )
 from lp.services.job.runner import BaseRunnableJob
+from lp.services.propertycache import get_property_cache
 
 
 class OCIRecipeBuildJobType(DBEnumeratedType):
     """Values that `OCIBuildJobType.job_type` can take."""
 
-    # XXX twom (2020-04-02) This does not currently have a concrete
-    # implementation, awaiting registry upload.
-
     REGISTRY_UPLOAD = DBItem(0, """
         Registry upload
 
@@ -48,6 +58,12 @@ class OCIRecipeBuildJobType(DBEnumeratedType):
         """)
 
 
+@implementer(IOCIRecipeBuildRegistryUploadStatusChangedEvent)
+class OCIRecipeBuildegistryUploadStatusChangedEvent(ObjectEvent):
+    """See `IOCIRecipeBuildRegistryUploadStatusChangedEvent`."""
+
+
+
 @implementer(IOCIRecipeBuildJob)
 class OCIRecipeBuildJob(StormBase):
     """See `IOCIRecipeBuildJob`."""
@@ -82,7 +98,7 @@ class OCIRecipeBuildJob(StormBase):
         self.json_data = json_data
 
     def makeDerived(self):
-        return OCIRecipeBuildJob.makeSubclass(self)
+        return OCIRecipeBuildJobDerived.makeSubclass(self)
 
 
 @delegate_to(IOCIRecipeBuildJob)
@@ -138,3 +154,35 @@ class OCIRecipeBuildJobDerived(BaseRunnableJob):
             ('oci_project_name', self.context.build.recipe.oci_project.name)
             ])
         return oops_vars
+
+
+@implementer(IOCIRegistryUploadJob)
+@provider(IOCIRegistryUploadJobSource)
+class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
+
+    class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD
+
+    @classmethod
+    def create(cls, build):
+        """See `IOCIRegistryUploadJobSource`"""
+        oci_build_job = OCIRecipeBuildJob(
+            build, cls.class_job_type, {})
+        job = cls(oci_build_job)
+        job.celeryRunOnCommit()
+        del get_property_cache(build).last_registry_upload_job
+        notify(OCIRecipeBuildegistryUploadStatusChangedEvent(build))
+        return job
+
+    @property
+    def error_message(self):
+        """See `IOCIRegistryUploadJob`."""
+        return self.json_data.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `IOCIRegistryUploadJob`."""
+        self.json_data["error_message"] = message
+
+    def run(self):
+        """See `IRunnableJob`."""
+        OCIRegistryClient.upload(self.build)
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
new file mode 100644
index 0000000..2f892b5
--- /dev/null
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -0,0 +1,264 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Client for talking to an OCI registry."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRegistryClient'
+]
+
+
+from io import BytesIO
+import hashlib
+import json
+import logging
+import tarfile
+
+from requests.exceptions import HTTPError
+from zope.interface import implementer
+
+from lp.oci.interfaces.ociregistryclient import (
+    BlobUploadFailed,
+    IOCIRegistryClient,
+    ManifestUploadFailed,
+    )
+from lp.services.timeout import urlfetch
+
+log = logging.getLogger("ociregistryclient")
+
+
+@implementer(IOCIRegistryClient)
+class OCIRegistryClient:
+
+    @classmethod
+    def _getJSONfile(cls, reference):
+        """Read JSON out of a `LibraryFileAlias`."""
+        _, lfa, lfc = reference
+        try:
+            lfa.open()
+            return json.loads(lfa.read())
+        finally:
+            lfa.close()
+
+    @classmethod
+    def _upload(cls, digest, push_rule, name, fileobj):
+        """Upload a blob to the registry, using a given digest.
+
+        :param digest: The digest to store the file under.
+        :param push_rule: `OCIPushRule` to use for the URL and credentials.
+        :param name: Name of the image the blob is part of.
+        :param fileobj: An object that looks like a buffer.
+
+        :raises BlobUploadFailed: if the registry does not accept the blob.
+        """
+        # Check if it already exists
+        try:
+            head_response = urlfetch(
+                "{}/v2/{}/blobs/{}".format(
+                    push_rule.registry_credentials.url, name, digest),
+                method="HEAD")
+            if head_response.status_code == 200:
+                log.info("{} already found".format(digest))
+                return
+        except HTTPError as http_error:
+            # A 404 is fine, we're about to upload the layer anyway
+            if http_error.response.status_code != 404:
+                raise http_error
+
+        post_response = urlfetch(
+            "{}/v2/{}/blobs/uploads/".format(
+                push_rule.registry_credentials.url, name),
+            method="POST")
+
+        post_location = post_response.headers["Location"]
+        query_parsed = {"digest": digest}
+
+        put_response = urlfetch(
+            post_location,
+            params=query_parsed,
+            data=fileobj,
+            method="PUT")
+
+        if put_response.status_code != 201:
+            raise BlobUploadFailed(
+                "Upload of {} for {} failed".format(digest, name))
+
+    @classmethod
+    def _upload_layer(cls, digest, push_rule, name, lfa):
+        """Upload a layer blob to the registry.
+
+        Uses _upload, but opens the LFA and extracts the necessary files
+        from the .tar.gz first.
+
+        :param digest: The digest to store the file under.
+        :param push_rule: `OCIPushRule` to use for the URL and credentials.
+        :param name: Name of the image the blob is part of.
+        :param lfa: The `LibraryFileAlias` for the layer.
+        """
+        lfa.open()
+        try:
+            un_zipped = tarfile.open(fileobj=lfa, mode='r|gz')
+            for tarinfo in un_zipped:
+                if tarinfo.name != 'layer.tar':
+                    continue
+                fileobj = un_zipped.extractfile(tarinfo)
+                cls._upload(digest, push_rule, name, fileobj)
+        finally:
+            lfa.close()
+
+    @classmethod
+    def _build_registry_manifest(cls, digests,
+                                 config, config_json, config_sha):
+        """Create an image manifest for the uploading image.
+
+        This involves nearly everything as digests and lengths are required.
+        This method creates a minimal manifest, some fields are missing.
+
+        :param digests: Dict of the various digests involved.
+        :param config: The contents of the manifest config file as a dict.
+        :param config_json: The config file as a JSON string.
+        :param config_sha: The sha256sum of the config JSON string.
+        """
+
+        # Create the initial manifest data with empty layer information
+        manifest = {
+            "schemaVersion": 2,
+            "mediaType":
+                "application/vnd.docker.distribution.manifest.v2+json",
+            "config": {
+                "mediaType": "application/vnd.docker.container.image.v1+json",
+                "size": len(config_json),
+                "digest": "sha256:{}".format(config_sha),
+            },
+            "layers": []}
+
+        # Fill in the layer information
+        for layer in config["rootfs"]["diff_ids"]:
+            manifest["layers"].append({
+                "mediaType":
+                    "application/vnd.docker.image.rootfs.diff.tar.gzip",
+                "size": 0,  # XXX twom 2020-04-14 We can get this from LFA
+                "digest": layer})
+        return manifest
+
+    @classmethod
+    def _preloadFiles(cls, build, manifest, digests):
+        """Preload the data from the librarian to avoid multiple fetches
+        if there is more than one push rule for a build.
+
+        :param build: The referencing `OCIRecipeBuild`.
+        :param manifest: The manifest from the built image.
+        :param digests: Dict of the various digests involved.
+        """
+        data = {}
+        for section in manifest:
+            # Load the matching config file for this section
+            config = cls._getJSONfile(
+                build.getByFileName(section['Config']))
+            files = {"config_file": config}
+            for diff_id in config["rootfs"]["diff_ids"]:
+                # We may have already seen this diff ID.
+                if files.get(diff_id):
+                    continue
+                # Retrieve the layer files.
+                # This doesn't read the content, so there is potential
+                # for multiple fetches, but the files can be arbitary size
+                # Potentially gigabytes.
+                files[diff_id] = {}
+                source_digest = digests[diff_id]["digest"]
+                _, lfa, _ = build.getLayerFileByDigest(source_digest)
+                files[diff_id] = lfa
+            data[section["Config"]] = files
+        return data
+
+    @classmethod
+    def _calculateName(cls, build, push_rule):
+        """Work out what the name for the image should be.
+
+        :param build: `OCIRecipeBuild` representing this build.
+        :param push_rule: `OCIPushRule` that we are using.
+        """
+        return "{}/{}".format(
+            build.recipe.oci_project.pillar.name,
+            push_rule.image_name)
+
+    @classmethod
+    def _calculateTag(cls, build, push_rule):
+        """Work out the base tag for the image should be.
+
+        :param build: `OCIRecipeBuild` representing this build.
+        :param push_rule: `OCIPushRule` that we are using.
+        """
+        # XXX twom 2020-04-17 This needs to include OCIProjectSeries and
+        # base image name
+
+        return "{}".format("edge")
+
+    @classmethod
+    def upload(cls, build):
+        """Upload the artifacts from an OCIRecipeBuild to a registry.
+
+        :param build: `OCIRecipeBuild` representing this build.
+        :raises ManifestUploadFailed: If the final registry manifest fails to
+                                      upload due to network or validity.
+        """
+        # Get the required metadata files
+        manifest = cls._getJSONfile(build.manifest)
+        digests_list = cls._getJSONfile(build.digests)
+        digests = {}
+        for digest_dict in digests_list:
+            for k, v in digest_dict.items():
+                digests[k] = v
+
+        # Preload the requested files
+        preloaded_data = cls._preloadFiles(build, manifest, digests)
+
+        for push_rule in build.recipe.push_rules:
+            for section in manifest:
+                # Work out names and tags
+                image_name = cls._calculateName(build, push_rule)
+                tag = cls._calculateTag(build, push_rule)
+                file_data = preloaded_data[section["Config"]]
+                config = file_data["config_file"]
+                #  Upload the layers involved
+                for diff_id in config["rootfs"]["diff_ids"]:
+                    cls._upload_layer(
+                        diff_id,
+                        push_rule,
+                        image_name,
+                        file_data[diff_id])
+                # The config file is required in different forms, so we can
+                # calculate the sha, work these out and upload
+                config_json = json.dumps(config).encode()
+                config_sha = hashlib.sha256(config_json).hexdigest()
+                cls._upload(
+                    "sha256:{}".format(config_sha),
+                    push_rule,
+                    image_name,
+                    BytesIO(config_json))
+
+                # Build the registry manifest from the image manifest
+                # and associated configs
+                registry_manifest = cls._build_registry_manifest(
+                    digests, config, config_json, config_sha)
+
+                # Upload the registry manifest
+                manifest_response = urlfetch(
+                    "{}/v2/{}/manifests/{}".format(
+                        push_rule.registry_credentials.url,
+                        image_name,
+                        tag),
+                    json=registry_manifest,
+                    headers={
+                        "Content-Type":
+                            "application/"
+                            "vnd.docker.distribution.manifest.v2+json"
+                        },
+                    method="PUT")
+                if manifest_response.status_code != 201:
+                    raise ManifestUploadFailed(
+                        "Failed to upload manifest for {} in {}".format(
+                            build.recipe.name, build.id))
diff --git a/lib/lp/oci/model/ociregistrycredentials.py b/lib/lp/oci/model/ociregistrycredentials.py
index 2a70159..03b0f72 100644
--- a/lib/lp/oci/model/ociregistrycredentials.py
+++ b/lib/lp/oci/model/ociregistrycredentials.py
@@ -80,8 +80,8 @@ class OCIRegistryCredentials(Storm):
     def getCredentials(self):
         container = getUtility(IEncryptedContainer, "oci-registry-secrets")
         try:
-            return json.loads(container.decrypt((
-                self._credentials['credentials_encrypted'])).decode("UTF-8"))
+            return json.loads(container.decrypt(
+                self._credentials['credentials_encrypted']).decode("UTF-8"))
         except CryptoError as e:
             # XXX twom 2020-03-18 This needs a better error
             # see SnapStoreClient.UnauthorizedUploadResponse
diff --git a/lib/lp/oci/subscribers/ocirecipebuild.py b/lib/lp/oci/subscribers/ocirecipebuild.py
index 3e7f80d..4703e3e 100644
--- a/lib/lp/oci/subscribers/ocirecipebuild.py
+++ b/lib/lp/oci/subscribers/ocirecipebuild.py
@@ -9,8 +9,10 @@ __metaclass__ = type
 
 from zope.component import getUtility
 
+from lp.buildmaster.enums import BuildStatus
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
 from lp.services.features import getFeatureFlag
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.interfaces import IWebhookSet
@@ -40,3 +42,6 @@ def oci_recipe_build_status_changed(build, event):
     if event.edited_fields is not None:
         if "status" in event.edited_fields:
             _trigger_oci_recipe_build_webhook(build, "status-changed")
+    if (build.recipe.can_upload_to_registry and
+            build.status == BuildStatus.FULLYBUILT):
+        getUtility(IOCIRegistryUploadJobSource).create(build)
diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py
index cf85684..158892b 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildjob.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py
@@ -7,16 +7,25 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 
+from fixtures import FakeLogger
+
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
-from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob
+from lp.oci.interfaces.ocirecipebuildjob import (
+    IOCIRecipeBuildJob,
+    IOCIRegistryUploadJob,
+    )
 from lp.oci.model.ocirecipebuildjob import (
     OCIRecipeBuildJob,
     OCIRecipeBuildJobDerived,
     OCIRecipeBuildJobType,
+    OCIRegistryUploadJob,
     )
 from lp.services.features.testing import FeatureFixture
 from lp.testing import TestCaseWithFactory
-from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
 
 
 class FakeOCIBuildJob(OCIRecipeBuildJobDerived):
@@ -52,3 +61,24 @@ class TestOCIRecipeBuildJob(TestCaseWithFactory):
             ('oci_project_name', oci_build.recipe.oci_project.name),
             ]
         self.assertEqual(expected, oops)
+
+
+class TestOCIRegistryUploadJobJob(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestOCIRegistryUploadJobJob, self).setUp()
+        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+
+    def test_provides_interface(self):
+        # `OCIRegistryUploadJob` objects provide `IOCIRegistryUploadJob`.
+        ocibuild = self.factory.makeOCIRecipeBuild()
+        job = OCIRegistryUploadJob.create(ocibuild)
+        self.assertProvides(job, IOCIRegistryUploadJob)
+
+    def test_run(self):
+        logger = self.useFixture(FakeLogger())
+        ocibuild = self.factory.makeOCIRecipeBuild()
+        self.assertContentEqual([], ocibuild.registry_upload_jobs)
+        job = OCIRegistryUploadJob.create(ocibuild)
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
new file mode 100644
index 0000000..9345ec7
--- /dev/null
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -0,0 +1,206 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the OCI Registry client."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import json
+
+from fixtures import MockPatch
+import responses
+import transaction
+
+from lp.oci.model.ociregistryclient import OCIRegistryClient
+from lp.oci.tests.helpers import OCIConfigHelperMixin
+
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import LaunchpadZopelessLayer
+from testtools.matchers import Equals, MatchesDict, MatchesListwise
+from requests.exceptions import HTTPError
+
+
+class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestOCIRegistryClient, self).setUp()
+        self.setConfig()
+        self.manifest = [{
+            "Config": "config_file_1.json",
+            "Layers": ["layer_1/layer.tar", "layer_2/layer.tar"]}]
+        self.digests = [{
+            "diff_id_1": {
+                "digest": "digest_1",
+                "source": "test/base_1",
+                "layer_id": "layer_1"
+            },
+            "diff_id_2": {
+                "digest": "digest_2",
+                "source": "",
+                "layer_id": "layer_2"
+            }
+        }]
+        self.config = {"rootfs": {"diff_ids": ["diff_id_1", "diff_id_2"]}}
+        self.build = self.factory.makeOCIRecipeBuild()
+        self.factory.makeOCIPushRule(recipe=self.build.recipe)
+        self.client = OCIRegistryClient()
+
+    def _makeFiles(self):
+        self.factory.makeOCIFile(
+            build=self.build,
+            content=json.dumps(self.manifest),
+            filename='manifest.json',
+        )
+        self.factory.makeOCIFile(
+            build=self.build,
+            content=json.dumps(self.digests),
+            filename='digests.json',
+        )
+        self.factory.makeOCIFile(
+            build=self.build,
+            content=json.dumps(self.config),
+            filename='config_file_1.json'
+        )
+
+        # make layer files
+        self.layer_1_file = self.factory.makeOCIFile(
+            build=self.build,
+            content="digest_1",
+            filename="digest_1_filename",
+            layer_file_digest="digest_1"
+        )
+        self.layer_2_file = self.factory.makeOCIFile(
+            build=self.build,
+            content="digest_2",
+            filename="digest_2_filename",
+            layer_file_digest="digest_2"
+        )
+
+        transaction.commit()
+
+    @responses.activate
+    def test_upload(self):
+        self._makeFiles()
+        self.useFixture(MockPatch(
+            "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
+        self.useFixture(MockPatch(
+            "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))
+
+        manifests_url = "{}/v2/{}/{}/manifests/edge".format(
+            self.build.recipe.push_rules[0].registry_credentials.url,
+            self.build.recipe.oci_project.pillar.name,
+            self.build.recipe.push_rules[0].image_name
+        )
+        responses.add("PUT", manifests_url, status=201)
+        self.client.upload(self.build)
+
+        request = json.loads(responses.calls[0].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(0)}),
+                MatchesDict({
+                    "mediaType": Equals(
+                        "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+                    "digest": Equals("diff_id_2"),
+                    "size": Equals(0)})
+            ]),
+            "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")
+        }))
+
+    def test_preloadFiles(self):
+        self._makeFiles()
+        files = self.client._preloadFiles(
+            self.build, self.manifest, self.digests[0])
+
+        self.assertThat(files, MatchesDict({
+            'config_file_1.json': MatchesDict({
+                'config_file': Equals(self.config),
+                'diff_id_1': Equals(self.layer_1_file.library_file),
+                'diff_id_2': Equals(self.layer_2_file.library_file)})}))
+
+    def test_calculateTag(self):
+        result = self.client._calculateTag(
+            self.build, self.build.recipe.push_rules[0])
+        self.assertEqual("edge", result)
+
+    def test_calculateName(self):
+        result = self.client._calculateName(
+            self.build, self.build.recipe.push_rules[0])
+        expected = "{}/{}".format(
+            self.build.recipe.oci_project.pillar.name,
+            self.build.recipe.push_rules[0].image_name)
+        self.assertEqual(expected, result)
+
+    def test_build_registry_manifest(self):
+        manifest = self.client._build_registry_manifest(
+            self.digests[0],
+            self.config,
+            json.dumps(self.config),
+            "config-sha")
+        self.assertThat(manifest, MatchesDict({
+            "layers": MatchesListwise([
+                MatchesDict({
+                    "mediaType": Equals(
+                        "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+                    "digest": Equals("diff_id_1"),
+                    "size": Equals(0)}),
+                MatchesDict({
+                    "mediaType": Equals(
+                        "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+                    "digest": Equals("diff_id_2"),
+                    "size": Equals(0)})
+            ]),
+            "schemaVersion": Equals(2),
+            "config": MatchesDict({
+                "mediaType": Equals(
+                    "application/vnd.docker.container.image.v1+json"),
+                "digest": Equals("sha256:config-sha"),
+                "size": Equals(52)
+            }),
+            "mediaType": Equals(
+                "application/vnd.docker.distribution.manifest.v2+json")
+        }))
+
+    @responses.activate
+    def test_upload_handles_existing(self):
+        blobs_url = "{}/v2/{}/blobs/{}".format(
+            self.build.recipe.push_rules[0].registry_credentials.url,
+            "test-name",
+            "test-digest")
+        responses.add("HEAD", blobs_url, status=200)
+        self.client._upload(
+            "test-digest", self.build.recipe.push_rules[0], "test-name", None)
+
+    @responses.activate
+    def test_upload_raises_non_404(self):
+        blobs_url = "{}/v2/{}/blobs/{}".format(
+            self.build.recipe.push_rules[0].registry_credentials.url,
+            "test-name",
+            "test-digest")
+        responses.add("HEAD", blobs_url, status=500)
+        self.assertRaises(
+            HTTPError,
+            self.client._upload,
+            "test-digest",
+            self.build.recipe.push_rules[0],
+            "test-name",
+            None)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index c43a908..b8f1c33 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1815,6 +1815,7 @@ job_sources:
     IExpiringMembershipNotificationJobSource,
     IGitRepositoryModifiedMailJobSource,
     IMembershipNotificationJobSource,
+    IOCIRegistryUploadJobSource,
     IPackageUploadNotificationJobSource,
     IPersonDeactivateJobSource,
     IPersonMergeJobSource,
@@ -1978,6 +1979,11 @@ module: lp.snappy.interfaces.snapbuildjob
 dbuser: snap-build-job
 crontab_group: MAIN
 
+[IOCIRegistryUploadJobSource]
+module: lp.oci.interfaces.ocirecipebuildjob
+dbuser: oci-build-job
+crontab_group: MAIN
+
 [ITeamInvitationNotificationJobSource]
 module: lp.registry.interfaces.persontransferjob
 dbuser: person-transfer-job