← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charmhub-client-push-release into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-client-push-release into launchpad:master.

Commit message:
Extend Charmhub client to handle pushing and releasing charms

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

I've only checked this against the publishergw API by eye.  A later branch will add an upload job, at which point we'll be able to do end-to-end testing.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-client-push-release into launchpad:master.
diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py
index 8beb251..4e9a528 100644
--- a/lib/lp/charms/interfaces/charmhubclient.py
+++ b/lib/lp/charms/interfaces/charmhubclient.py
@@ -6,7 +6,13 @@
 __all__ = [
     "BadExchangeMacaroonsResponse",
     "BadRequestPackageUploadResponse",
+    "BadReviewStatusResponse",
     "ICharmhubClient",
+    "ReleaseFailedResponse",
+    "ReviewFailedResponse",
+    "UnauthorizedUploadResponse",
+    "UploadFailedResponse",
+    "UploadNotReviewedYetResponse",
     ]
 
 import http.client
@@ -33,6 +39,30 @@ class BadExchangeMacaroonsResponse(CharmhubError):
     pass
 
 
+class UploadFailedResponse(CharmhubError):
+    pass
+
+
+class UnauthorizedUploadResponse(CharmhubError):
+    pass
+
+
+class BadReviewStatusResponse(CharmhubError):
+    pass
+
+
+class UploadNotReviewedYetResponse(CharmhubError):
+    pass
+
+
+class ReviewFailedResponse(CharmhubError):
+    pass
+
+
+class ReleaseFailedResponse(CharmhubError):
+    pass
+
+
 class ICharmhubClient(Interface):
     """Interface for the API provided by Charmhub."""
 
@@ -61,3 +91,36 @@ class ICharmhubClient(Interface):
         :return: A serialized macaroon from Charmhub with no third-party
             Candid caveat.
         """
+
+    def upload(build):
+        """Upload a charm recipe build to CharmHub.
+
+        :param build: The `ICharmRecipeBuild` to upload.
+        :return: A URL to poll for upload processing status.
+        :raises UnauthorizedUploadResponse: if the user who authorised this
+            upload is not themselves authorised to upload the snap in
+            question.
+        :raises UploadFailedResponse: if uploading the build to Charmhub
+            failed.
+        """
+
+    def checkStatus(status_url):
+        """Poll Charmhub once for upload scan status.
+
+        :param status_url: A URL as returned by `upload`.
+        :raises UploadNotReviewedYetResponse: if the upload has not yet been
+            reviewed.
+        :raises BadReviewStatusResponse: if Charmhub failed to review the
+            upload.
+        :return: The Charmhub revision number for the upload.
+        """
+
+    def release(build, revision):
+        """Tell Charmhub to release a build to specified channels.
+
+        :param build: The `ICharmRecipeBuild` to release.
+        :param revision: The revision returned by Charmhub when uploading
+            the build.
+        :raises ReleaseFailedResponse: if Charmhub failed to release the
+            build.
+        """
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index c6dac7d..46195b9 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -274,6 +274,12 @@ class ICharmRecipeView(Interface):
         title=_("Private"), required=False, readonly=False,
         description=_("Whether this charm recipe is private."))
 
+    can_upload_to_store = Bool(
+        title=_("Can upload to Charmhub"), required=True, readonly=True,
+        description=_(
+            "Whether everything is set up to allow uploading builds of this "
+            "charm recipe to Charmhub."))
+
     def getAllowedInformationTypes(user):
         """Get a list of acceptable `InformationType`s for this charm recipe.
 
diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py
index be195d3..9b2627f 100644
--- a/lib/lp/charms/model/charmhubclient.py
+++ b/lib/lp/charms/model/charmhubclient.py
@@ -8,24 +8,53 @@ __all__ = [
     ]
 
 from base64 import b64encode
+from urllib.parse import quote
 
 from lazr.restful.utils import get_current_browser_request
 from pymacaroons import Macaroon
 from pymacaroons.serializers import JsonSerializer
 import requests
+from requests_toolbelt import MultipartEncoder
+from zope.component import getUtility
 from zope.interface import implementer
 
 from lp.charms.interfaces.charmhubclient import (
     BadExchangeMacaroonsResponse,
     BadRequestPackageUploadResponse,
+    BadReviewStatusResponse,
     ICharmhubClient,
+    ReleaseFailedResponse,
+    ReviewFailedResponse,
+    UnauthorizedUploadResponse,
+    UploadFailedResponse,
+    UploadNotReviewedYetResponse,
     )
 from lp.services.config import config
+from lp.services.crypto.interfaces import (
+    CryptoError,
+    IEncryptedContainer,
+    )
+from lp.services.librarian.utils import EncodableLibraryFileAlias
 from lp.services.timeline.requesttimeline import get_request_timeline
 from lp.services.timeout import urlfetch
 from lp.services.webapp.url import urlappend
 
 
+def _get_macaroon(recipe):
+    """Get the Charmhub macaroon for a recipe."""
+    store_secrets = recipe.store_secrets or {}
+    macaroon_raw = store_secrets.get("exchanged_encrypted")
+    if macaroon_raw is None:
+        raise UnauthorizedUploadResponse(
+            "{} is not authorized for upload to Charmhub".format(recipe))
+    container = getUtility(IEncryptedContainer, "charmhub-secrets")
+    try:
+        return container.decrypt(macaroon_raw).decode()
+    except CryptoError as e:
+        raise UnauthorizedUploadResponse(
+            "Failed to decrypt macaroon: {}".format(e))
+
+
 @implementer(ICharmhubClient)
 class CharmhubClient:
     """A client for the API provided by Charmhub."""
@@ -48,10 +77,10 @@ class CharmhubClient:
             except ValueError:
                 pass
             else:
-                if "error_list" in response_data:
+                if "error-list" in response_data:
                     error_message = "\n".join(
                         error["message"]
-                        for error in response_data["error_list"])
+                        for error in response_data["error-list"])
         detail = requests_error.response.content.decode(errors="replace")
         can_retry = requests_error.response.status_code in (502, 503)
         return error_class(error_message, detail=detail, can_retry=can_retry)
@@ -115,3 +144,136 @@ class CharmhubClient:
             raise cls._makeCharmhubError(BadExchangeMacaroonsResponse, e)
         finally:
             timeline_action.finish()
+
+    @classmethod
+    def _uploadToStorage(cls, lfa):
+        """Upload a single file to Charmhub's storage."""
+        assert config.charms.charmhub_storage_url is not None
+        unscanned_upload_url = urlappend(
+            config.charms.charmhub_storage_url, "unscanned-upload/")
+        lfa.open()
+        try:
+            lfa_wrapper = EncodableLibraryFileAlias(lfa)
+            encoder = MultipartEncoder(
+                fields={
+                    "binary": (
+                        lfa.filename, lfa_wrapper, "application/octet-stream"),
+                    })
+            request = get_current_browser_request()
+            timeline_action = get_request_timeline(request).start(
+                "charm-storage-push", lfa.filename, allow_nested=True)
+            try:
+                response = urlfetch(
+                    unscanned_upload_url, method="POST", data=encoder,
+                    headers={
+                        "Content-Type": encoder.content_type,
+                        "Accept": "application/json",
+                        })
+                response_data = response.json()
+                if not response_data.get("successful", False):
+                    raise UploadFailedResponse(response.text)
+                return response_data["upload_id"]
+            except requests.HTTPError as e:
+                raise cls._makeCharmhubError(UploadFailedResponse, e)
+            finally:
+                timeline_action.finish()
+        finally:
+            lfa.close()
+
+    @classmethod
+    def _push(cls, build, upload_id):
+        """Push an already-uploaded charm to Charmhub."""
+        recipe = build.recipe
+        assert config.charms.charmhub_url is not None
+        assert recipe.store_name is not None
+        assert recipe.store_secrets is not None
+        push_url = urlappend(
+            config.charms.charmhub_url,
+            "v1/charm/{}/revisions".format(quote(recipe.store_name)))
+        macaroon_raw = _get_macaroon(recipe)
+        data = {"upload-id": upload_id}
+        request = get_current_browser_request()
+        timeline_action = get_request_timeline(request).start(
+            "charm-push", recipe.store_name, allow_nested=True)
+        try:
+            response = urlfetch(
+                push_url, method="POST",
+                headers={"Authorization": "Macaroon {}".format(macaroon_raw)},
+                json=data)
+            response_data = response.json()
+            return response_data["status-url"]
+        except requests.HTTPError as e:
+            if e.response.status_code == 401:
+                raise cls._makeCharmhubError(UnauthorizedUploadResponse, e)
+            else:
+                raise cls._makeCharmhubError(UploadFailedResponse, e)
+        finally:
+            timeline_action.finish()
+
+    @classmethod
+    def upload(cls, build):
+        """See `ICharmhubClient`."""
+        assert build.recipe.can_upload_to_store
+        for _, lfa, _ in build.getFiles():
+            if not lfa.filename.endswith(".charm"):
+                continue
+            upload_id = cls._uploadToStorage(lfa)
+            return cls._push(build, upload_id)
+
+    @classmethod
+    def checkStatus(cls, build, status_url):
+        """See `ICharmhubClient`."""
+        macaroon_raw = _get_macaroon(build.recipe)
+        request = get_current_browser_request()
+        timeline_action = get_request_timeline(request).start(
+            "charm-check-status", status_url, allow_nested=True)
+        try:
+            response = urlfetch(
+                status_url,
+                headers={"Authorization": "Macaroon {}".format(macaroon_raw)})
+            response_data = response.json()
+            # We're asking for a single upload ID, so the response should
+            # only have one revision.
+            if len(response_data.get("revisions", [])) != 1:
+                raise BadReviewStatusResponse(response.text)
+            [revision] = response_data["revisions"]
+            if revision["status"] == "approved":
+                return revision["revision"]
+            elif revision["status"] == "rejected":
+                error_message = "\n".join(
+                    error["message"] for error in revision["errors"])
+                raise ReviewFailedResponse(error_message)
+            else:
+                raise UploadNotReviewedYetResponse()
+        except requests.HTTPError as e:
+            raise cls._makeCharmhubError(BadReviewStatusResponse, e)
+        finally:
+            timeline_action.finish()
+
+    @classmethod
+    def release(cls, build, revision):
+        """See `ICharmhubClient`."""
+        assert config.charms.charmhub_url is not None
+        recipe = build.recipe
+        assert recipe.store_name is not None
+        assert recipe.store_secrets is not None
+        assert recipe.store_channels
+        release_url = urlappend(
+            config.charms.charmhub_url,
+            "v1/charm/{}/releases".format(quote(recipe.store_name)))
+        macaroon_raw = _get_macaroon(recipe)
+        data = [
+            {"channel": channel, "revision": revision}
+            for channel in recipe.store_channels]
+        request = get_current_browser_request()
+        timeline_action = get_request_timeline(request).start(
+            "charm-release", recipe.store_name, allow_nested=True)
+        try:
+            urlfetch(
+                release_url, method="POST",
+                headers={"Authorization": "Macaroon {}".format(macaroon_raw)},
+                json=data)
+        except requests.HTTPError as e:
+            raise cls._makeCharmhubError(ReleaseFailedResponse, e)
+        finally:
+            timeline_action.finish()
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index a3ce3b3..1907347 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -692,6 +692,14 @@ class CharmRecipe(StormBase):
             container.encrypt(exchanged_macaroon_raw.encode()))
         self.store_secrets.pop("root", None)
 
+    @property
+    def can_upload_to_store(self):
+        return (
+            config.charms.charmhub_url is not None and
+            self.store_name is not None and
+            self.store_secrets is not None and
+            "exchanged_encrypted" in self.store_secrets)
+
     def destroySelf(self):
         """See `ICharmRecipe`."""
         store = IStore(self)
diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
index b041055..864c0d6 100644
--- a/lib/lp/charms/tests/test_charmhubclient.py
+++ b/lib/lp/charms/tests/test_charmhubclient.py
@@ -4,68 +4,168 @@
 """Tests for communication with Charmhub."""
 
 import base64
+import hashlib
+import io
 import json
+from urllib.parse import quote
 
 from lazr.restful.utils import get_current_browser_request
-from pymacaroons import Macaroon
+import multipart
+from nacl.public import PrivateKey
+from pymacaroons import (
+    Macaroon,
+    Verifier,
+    )
 from pymacaroons.serializers import JsonSerializer
 import responses
 from testtools.matchers import (
     AfterPreprocessing,
     ContainsDict,
     Equals,
+    Is,
+    Matcher,
     MatchesAll,
+    MatchesDict,
+    MatchesListwise,
     MatchesStructure,
+    Mismatch,
     )
+import transaction
 from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
 
+from lp.buildmaster.enums import BuildStatus
 from lp.charms.interfaces.charmhubclient import (
     BadExchangeMacaroonsResponse,
     BadRequestPackageUploadResponse,
+    BadReviewStatusResponse,
     ICharmhubClient,
+    ReleaseFailedResponse,
+    ReviewFailedResponse,
+    UnauthorizedUploadResponse,
+    UploadFailedResponse,
+    UploadNotReviewedYetResponse,
     )
 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.services.crypto.interfaces import IEncryptedContainer
 from lp.services.features.testing import FeatureFixture
 from lp.services.timeline.requesttimeline import get_request_timeline
 from lp.testing import TestCaseWithFactory
-from lp.testing.layers import ZopelessDatabaseLayer
+from lp.testing.dbuser import dbuser
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class MacaroonVerifies(Matcher):
+    """Matches if a serialized macaroon passes verification."""
+
+    def __init__(self, key):
+        self.key = key
 
+    def match(self, macaroon_raw):
+        macaroon = Macaroon.deserialize(macaroon_raw)
+        try:
+            Verifier().verify(macaroon, self.key)
+        except Exception as e:
+            return Mismatch("Macaroon does not verify: %s" % e)
 
-class RequestMatches(MatchesStructure):
+
+class RequestMatches(MatchesAll):
     """Matches a request with the specified attributes."""
 
-    def __init__(self, macaroons=None, json_data=None, **kwargs):
+    def __init__(self, macaroons=None, auth=None, json_data=None,
+                 file_data=None, **kwargs):
+        matchers = []
         kwargs = dict(kwargs)
         if macaroons is not None:
-            headers_matcher = ContainsDict({
+            matchers.append(MatchesStructure(headers=ContainsDict({
                 "Macaroons": AfterPreprocessing(
                     lambda v: json.loads(
                         base64.b64decode(v.encode()).decode()),
                     Equals([json.loads(m) for m in macaroons])),
-                })
-            if kwargs.get("headers"):
-                headers_matcher = MatchesAll(
-                    kwargs["headers"], headers_matcher)
-            kwargs["headers"] = headers_matcher
+                })))
+        if auth is not None:
+            auth_scheme, auth_params_matcher = auth
+            matchers.append(MatchesStructure(headers=ContainsDict({
+                "Authorization": AfterPreprocessing(
+                    lambda v: v.split(" ", 1),
+                    MatchesListwise([
+                        Equals(auth_scheme),
+                        auth_params_matcher,
+                        ])),
+                })))
         if json_data is not None:
-            body_matcher = AfterPreprocessing(
-                lambda b: json.loads(b.decode()), Equals(json_data))
-            if kwargs.get("body"):
-                body_matcher = MatchesAll(kwargs["body"], body_matcher)
-            kwargs["body"] = body_matcher
-        super().__init__(**kwargs)
+            matchers.append(MatchesStructure(body=AfterPreprocessing(
+                lambda b: json.loads(b.decode()), Equals(json_data))))
+        elif file_data is not None:
+            matchers.append(AfterPreprocessing(
+                lambda r: multipart.parse_form_data({
+                    "REQUEST_METHOD": r.method,
+                    "CONTENT_TYPE": r.headers["Content-Type"],
+                    "CONTENT_LENGTH": r.headers["Content-Length"],
+                    "wsgi.input": io.BytesIO(
+                        r.body.read() if hasattr(r.body, "read") else r.body),
+                    })[1],
+                MatchesDict(file_data)))
+        if kwargs:
+            matchers.append(MatchesStructure(**kwargs))
+        super().__init__(*matchers)
 
 
 class TestCharmhubClient(TestCaseWithFactory):
 
-    layer = ZopelessDatabaseLayer
+    layer = LaunchpadZopelessLayer
 
     def setUp(self):
         super().setUp()
         self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
-        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        self.pushConfig(
+            "charms",
+            charmhub_url="http://charmhub.example/";,
+            charmhub_storage_url="http://storage.charmhub.example/";)
         self.client = getUtility(ICharmhubClient)
 
+    def _setUpSecretStorage(self):
+        self.private_key = PrivateKey.generate()
+        self.pushConfig(
+            "charms",
+            charmhub_secrets_public_key=base64.b64encode(
+                bytes(self.private_key.public_key)).decode(),
+            charmhub_secrets_private_key=base64.b64encode(
+                bytes(self.private_key)).decode())
+
+    def _makeStoreSecrets(self):
+        self.exchanged_key = hashlib.sha256(
+            self.factory.getUniqueBytes()).hexdigest()
+        exchanged_macaroon = Macaroon(key=self.exchanged_key)
+        container = getUtility(IEncryptedContainer, "charmhub-secrets")
+        return {
+            "exchanged_encrypted": removeSecurityProxy(container.encrypt(
+                exchanged_macaroon.serialize().encode())),
+            }
+
+    def _addUnscannedUploadResponse(self):
+        responses.add(
+            "POST", "http://storage.charmhub.example/unscanned-upload/";,
+            json={"successful": True, "upload_id": 1})
+
+    def _addCharmPushResponse(self, name):
+        responses.add(
+            "POST",
+            "http://charmhub.example/v1/charm/{}/revisions".format(
+                quote(name)),
+            status=200,
+            json={
+                "status-url": (
+                    "http://charmhub.example/v1/charm/{}/revisions/review";
+                    "?upload-id=123".format(quote(name))),
+                })
+
+    def _addCharmReleaseResponse(self, name):
+        responses.add(
+            "POST",
+            "http://charmhub.example/v1/charm/{}/releases".format(quote(name)),
+            json={})
+
     @responses.activate
     def test_requestPackageUploadPermission(self):
         responses.add(
@@ -104,7 +204,7 @@ class TestCharmhubClient(TestCaseWithFactory):
     def test_requestPackageUploadPermission_error(self):
         responses.add(
             "POST", "http://charmhub.example/v1/tokens";,
-            status=503, json={"error_list": [{"message": "Failed"}]})
+            status=503, json={"error-list": [{"message": "Failed"}]})
         self.assertRaisesWithContent(
             BadRequestPackageUploadResponse, "Failed",
             self.client.requestPackageUploadPermission, "test-charm")
@@ -161,7 +261,7 @@ class TestCharmhubClient(TestCaseWithFactory):
         responses.add(
             "POST", "http://charmhub.example/v1/tokens/exchange";,
             status=401,
-            json={"error_list": [{"message": "Exchange window expired"}]})
+            json={"error-list": [{"message": "Exchange window expired"}]})
         root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
         discharge_macaroon_raw = Macaroon(version=2).serialize(
             JsonSerializer())
@@ -182,3 +282,235 @@ class TestCharmhubClient(TestCaseWithFactory):
             "404 Client Error: Not Found",
             self.client.exchangeMacaroons,
             root_macaroon_raw, discharge_macaroon_raw)
+
+    def makeUploadableCharmRecipeBuild(self, store_secrets=None):
+        if store_secrets is None:
+            store_secrets = self._makeStoreSecrets()
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True,
+            store_name="test-charm", store_secrets=store_secrets)
+        build = self.factory.makeCharmRecipeBuild(recipe=recipe)
+        charm_lfa = self.factory.makeLibraryFileAlias(
+            filename="test-charm.charm", content="dummy charm content")
+        self.factory.makeCharmFile(build=build, library_file=charm_lfa)
+        manifest_lfa = self.factory.makeLibraryFileAlias(
+            filename="test-charm.manifest", content="dummy manifest content")
+        self.factory.makeCharmFile(build=build, library_file=manifest_lfa)
+        build.updateStatus(BuildStatus.BUILDING)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        return build
+
+    @responses.activate
+    def test_upload(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        transaction.commit()
+        self._addUnscannedUploadResponse()
+        self._addCharmPushResponse("test-charm")
+        # XXX cjwatson 2021-08-19: Use
+        # config.ICharmhubUploadJobSource.dbuser once that job exists.
+        with dbuser("charm-build-job"):
+            self.assertEqual(
+                "http://charmhub.example/v1/charm/test-charm/revisions/review";
+                "?upload-id=123",
+                self.client.upload(build))
+        requests = [call.request for call in responses.calls]
+        self.assertThat(requests, MatchesListwise([
+            RequestMatches(
+                url=Equals(
+                    "http://storage.charmhub.example/unscanned-upload/";),
+                method=Equals("POST"),
+                file_data={
+                    "binary": MatchesStructure.byEquality(
+                        name="binary", filename="test-charm.charm",
+                        value="dummy charm content",
+                        content_type="application/octet-stream",
+                        )}),
+            RequestMatches(
+                url=Equals(
+                    "http://charmhub.example/v1/charm/test-charm/revisions";),
+                method=Equals("POST"),
+                headers=ContainsDict(
+                    {"Content-Type": Equals("application/json")}),
+                auth=("Macaroon", MacaroonVerifies(self.exchanged_key)),
+                json_data={"upload-id": 1}),
+            ]))
+
+    @responses.activate
+    def test_upload_unauthorized(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        transaction.commit()
+        self._addUnscannedUploadResponse()
+        charm_push_error = {
+            "code": "permission-required",
+            "message": "Missing required permission: package-manage-revisions",
+            }
+        responses.add(
+            "POST", "http://charmhub.example/v1/charm/test-charm/revisions";,
+            status=401,
+            json={"error-list": [charm_push_error]})
+        # XXX cjwatson 2021-08-19: Use
+        # config.ICharmhubUploadJobSource.dbuser once that job exists.
+        with dbuser("charm-build-job"):
+            self.assertRaisesWithContent(
+                UnauthorizedUploadResponse,
+                "Missing required permission: package-manage-revisions",
+                self.client.upload, build)
+
+    @responses.activate
+    def test_upload_file_error(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        transaction.commit()
+        responses.add(
+            "POST", "http://storage.charmhub.example/unscanned-upload/";,
+            status=502, body="The proxy exploded.\n")
+        # XXX cjwatson 2021-08-19: Use
+        # config.ICharmhubUploadJobSource.dbuser once that job exists.
+        with dbuser("charm-build-job"):
+            err = self.assertRaises(
+                UploadFailedResponse, self.client.upload, build)
+            self.assertEqual("502 Server Error: Bad Gateway", str(err))
+            self.assertThat(err, MatchesStructure(
+                detail=Equals("The proxy exploded.\n"),
+                can_retry=Is(True)))
+
+    @responses.activate
+    def test_checkStatus_pending(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        status_url = (
+            "http://charmhub.example/v1/charm/test-charm/revisions/review";
+            "?upload-id=123")
+        responses.add(
+            "GET", status_url,
+            json={
+                "revisions": [
+                    {
+                        "upload-id": "123",
+                        "status": "new",
+                        "revision": None,
+                        "errors": None,
+                        },
+                    ],
+                })
+        self.assertRaises(
+            UploadNotReviewedYetResponse,
+            self.client.checkStatus, build, status_url)
+
+    @responses.activate
+    def test_checkStatus_error(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        status_url = (
+            "http://charmhub.example/v1/charm/test-charm/revisions/review";
+            "?upload-id=123")
+        responses.add(
+            "GET", status_url,
+            json={
+                "revisions": [
+                    {
+                        "upload-id": "123",
+                        "status": "rejected",
+                        "revision": None,
+                        "errors": [
+                            {"code": None, "message": "This charm is broken."},
+                            ],
+                        },
+                    ],
+                })
+        self.assertRaisesWithContent(
+            ReviewFailedResponse, "This charm is broken.",
+            self.client.checkStatus, build, status_url)
+
+    @responses.activate
+    def test_checkStatus_approved(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        status_url = (
+            "http://charmhub.example/v1/charm/test-charm/revisions/review";
+            "?upload-id=123")
+        responses.add(
+            "GET", status_url,
+            json={
+                "revisions": [
+                    {
+                        "upload-id": "123",
+                        "status": "approved",
+                        "revision": 1,
+                        "errors": None,
+                        },
+                    ],
+                })
+        self.assertEqual(1, self.client.checkStatus(build, status_url))
+        requests = [call.request for call in responses.calls]
+        self.assertThat(requests, MatchesListwise([
+            RequestMatches(
+                url=Equals(status_url),
+                method=Equals("GET"),
+                auth=("Macaroon", MacaroonVerifies(self.exchanged_key))),
+            ]))
+
+    @responses.activate
+    def test_checkStatus_404(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        status_url = (
+            "http://charmhub.example/v1/charm/test-charm/revisions/review";
+            "?upload-id=123")
+        responses.add("GET", status_url, status=404)
+        self.assertRaisesWithContent(
+            BadReviewStatusResponse, "404 Client Error: Not Found",
+            self.client.checkStatus, build, status_url)
+
+    @responses.activate
+    def test_release(self):
+        self._setUpSecretStorage()
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name="test-charm",
+            store_secrets=self._makeStoreSecrets(),
+            store_channels=["stable", "edge"])
+        build = self.factory.makeCharmRecipeBuild(recipe=recipe)
+        self._addCharmReleaseResponse("test-charm")
+        self.client.release(build, 1)
+        self.assertThat(responses.calls[-1].request, RequestMatches(
+            url=Equals("http://charmhub.example/v1/charm/test-charm/releases";),
+            method=Equals("POST"),
+            headers=ContainsDict({"Content-Type": Equals("application/json")}),
+            auth=("Macaroon", MacaroonVerifies(self.exchanged_key)),
+            json_data=[
+                {"channel": "stable", "revision": 1},
+                {"channel": "edge", "revision": 1},
+                ]))
+
+    @responses.activate
+    def test_release_error(self):
+        self._setUpSecretStorage()
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name="test-charm",
+            store_secrets=self._makeStoreSecrets(),
+            store_channels=["stable", "edge"])
+        build = self.factory.makeCharmRecipeBuild(recipe=recipe)
+        responses.add(
+            "POST", "http://charmhub.example/v1/charm/test-charm/releases";,
+            status=503,
+            json={"error-list": [{"message": "Failed to publish"}]})
+        self.assertRaisesWithContent(
+            ReleaseFailedResponse, "Failed to publish",
+            self.client.release, build, 1)
+
+    @responses.activate
+    def test_release_404(self):
+        self._setUpSecretStorage()
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name="test-charm",
+            store_secrets=self._makeStoreSecrets(),
+            store_channels=["stable", "edge"])
+        build = self.factory.makeCharmRecipeBuild(recipe=recipe)
+        responses.add(
+            "POST", "http://charmhub.example/v1/charm/test-charm/releases";,
+            status=404)
+        self.assertRaisesWithContent(
+            ReleaseFailedResponse, "404 Client Error: Not Found",
+            self.client.release, build, 1)
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 649b89a..4de58e8 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -47,6 +47,7 @@ from lp.oci.interfaces.ociregistryclient import (
     )
 from lp.services.config import config
 from lp.services.features import getFeatureFlag
+from lp.services.librarian.utils import EncodableLibraryFileAlias
 from lp.services.propertycache import cachedproperty
 from lp.services.timeout import urlfetch
 
@@ -71,33 +72,6 @@ def is_aws_bearer_token_domain(domain):
     return any(domain.endswith(i) for i in domains.split())
 
 
-class LibraryFileAliasWrapper:
-
-    """A `LibraryFileAlias` wrapper used to read an LFA.
-
-    The LFA is uploaded by Buildd to Librarian as tar.gz
-    after building the OCI image. Each LFA is essentially
-    a docker image layer and it is read in chunks when
-    uploading the layer to Dockerhub Registry."""
-
-    def __init__(self, lfa):
-        self.lfa = lfa
-        self.position = 0
-
-    def __len__(self):
-        return self.lfa.content.filesize - self.position
-
-    """ Reads from the LFA in chunks. See ILibraryFileAlias."""
-    def read(self, length=-1):
-        chunksize = None if length == -1 else length
-        data = self.lfa.read(chunksize=chunksize)
-        if chunksize is None:
-            self.position = self.lfa.content.filesize
-        else:
-            self.position += length
-        return data
-
-
 @implementer(IOCIRegistryClient)
 class OCIRegistryClient:
 
@@ -210,7 +184,7 @@ class OCIRegistryClient:
                         return tarinfo.size
             else:
                 size = lfa.content.filesize
-                wrapper = LibraryFileAliasWrapper(lfa)
+                wrapper = EncodableLibraryFileAlias(lfa)
                 cls._upload(
                     digest, push_rule, wrapper, size,
                     http_client)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index e6242b9..413e38b 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -155,6 +155,10 @@ cron_control_url: file:cronscripts.ini
 # datatype: urlbase
 charmhub_url: none
 
+# Charmhub's storage URL endpoint.
+# datatype: urlbase
+charmhub_storage_url: none
+
 # Base64-encoded NaCl private key for decrypting Charmhub upload tokens.
 # This should only be set in secret overlays on systems that need to perform
 # Charmhub uploads on behalf of users.
diff --git a/lib/lp/services/librarian/utils.py b/lib/lp/services/librarian/utils.py
index 3d627e1..bc0895b 100644
--- a/lib/lp/services/librarian/utils.py
+++ b/lib/lp/services/librarian/utils.py
@@ -1,9 +1,10 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 __all__ = [
     'copy_and_close',
+    'EncodableLibraryFileAlias',
     'filechunks',
     'guess_librarian_encoding',
     'sha1_from_path',
@@ -74,3 +75,23 @@ def guess_librarian_encoding(filename, mimetype):
         encoding = None
 
     return encoding, mimetype
+
+
+class EncodableLibraryFileAlias:
+    """A `LibraryFileAlias` wrapper usable with a `MultipartEncoder`."""
+
+    def __init__(self, lfa):
+        self.lfa = lfa
+        self.position = 0
+
+    def __len__(self):
+        return self.lfa.content.filesize - self.position
+
+    def read(self, length=-1):
+        chunksize = None if length == -1 else length
+        data = self.lfa.read(chunksize=chunksize)
+        if chunksize is None:
+            self.position = self.lfa.content.filesize
+        else:
+            self.position += length
+        return data
diff --git a/lib/lp/snappy/model/snapstoreclient.py b/lib/lp/snappy/model/snapstoreclient.py
index feba714..fd0415b 100644
--- a/lib/lp/snappy/model/snapstoreclient.py
+++ b/lib/lp/snappy/model/snapstoreclient.py
@@ -1,4 +1,4 @@
-# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Communication with the snap store."""
@@ -29,6 +29,7 @@ from lp.services.crypto.interfaces import (
     IEncryptedContainer,
     )
 from lp.services.features import getFeatureFlag
+from lp.services.librarian.utils import EncodableLibraryFileAlias
 from lp.services.memcache.interfaces import IMemcacheClient
 from lp.services.scripts import log
 from lp.services.timeline.requesttimeline import get_request_timeline
@@ -48,27 +49,6 @@ from lp.snappy.interfaces.snapstoreclient import (
     )
 
 
-class LibraryFileAliasWrapper:
-    """A `LibraryFileAlias` wrapper usable with a `MultipartEncoder`."""
-
-    def __init__(self, lfa):
-        self.lfa = lfa
-        self.position = 0
-
-    @property
-    def len(self):
-        return self.lfa.content.filesize - self.position
-
-    def read(self, length=-1):
-        chunksize = None if length == -1 else length
-        data = self.lfa.read(chunksize=chunksize)
-        if chunksize is None:
-            self.position = self.lfa.content.filesize
-        else:
-            self.position += length
-        return data
-
-
 class InvalidStoreSecretsError(Exception):
     pass
 
@@ -264,7 +244,7 @@ class SnapStoreClient:
             config.snappy.store_upload_url, "unscanned-upload/")
         lfa.open()
         try:
-            lfa_wrapper = LibraryFileAliasWrapper(lfa)
+            lfa_wrapper = EncodableLibraryFileAlias(lfa)
             encoder = MultipartEncoder(
                 fields={
                     "binary": (
diff --git a/setup.py b/setup.py
index 6e945a8..14763f8 100644
--- a/setup.py
+++ b/setup.py
@@ -182,6 +182,7 @@ setup(
         'lpjsmin',
         'Markdown',
         'meliae',
+        'multipart',
         'oauth',
         'oauthlib',
         'oops',