← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-store-client into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-store-client into lp:launchpad with lp:~cjwatson/launchpad/snap-upload-model as a prerequisite.

Commit message:
Add an initial client for uploading snaps to the store.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1572605 in Launchpad itself: "Automatically upload snap builds to store"
  https://bugs.launchpad.net/launchpad/+bug/1572605

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-store-client/+merge/293668

Add an initial client for uploading snaps to the store.

This isn't used anywhere yet, and the implementation will need to change to handle recently-agreed changes to how discharge macaroons are handled, but it's useful to have an outline in place.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-store-client into lp:launchpad.
=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2016-03-31 14:07:33 +0000
+++ lib/lp/services/config/schema-lazr.conf	2016-05-03 19:57:26 +0000
@@ -1779,6 +1779,12 @@
 # datatype: string
 tools_source: none
 
+# The store's primary URL endpoint.
+store_url: none
+
+# The store's upload URL endpoint.
+store_upload_url: none
+
 [process-job-source-groups]
 # This section is used by cronscripts/process-job-source-groups.py.
 dbuser: process-job-source-groups

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2016-05-03 19:57:25 +0000
+++ lib/lp/snappy/configure.zcml	2016-05-03 19:57:26 +0000
@@ -117,6 +117,13 @@
             interface="lp.snappy.interfaces.snapseries.ISnapDistroSeriesSet" />
     </securedutility>
 
+    <!-- Store interaction -->
+    <securedutility
+        class="lp.snappy.model.snapstoreclient.SnapStoreClient"
+        provides="lp.snappy.interfaces.snapstoreclient.ISnapStoreClient">
+        <allow interface="lp.snappy.interfaces.snapstoreclient.ISnapStoreClient" />
+    </securedutility>
+
     <webservice:register module="lp.snappy.interfaces.webservice" />
 
 </configure>

=== added file 'lib/lp/snappy/interfaces/snapstoreclient.py'
--- lib/lp/snappy/interfaces/snapstoreclient.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/snapstoreclient.py	2016-05-03 19:57:26 +0000
@@ -0,0 +1,43 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface for communication with the snap store."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'BadRequestPackageUploadResponse',
+    'BadUploadResponse',
+    'ISnapStoreClient',
+    ]
+
+from zope.interface import Interface
+
+
+class BadRequestPackageUploadResponse(Exception):
+    pass
+
+
+class BadUploadResponse(Exception):
+    pass
+
+
+class ISnapStoreClient(Interface):
+    """Interface for the API provided by the snap store."""
+
+    def requestPackageUpload(snap_series, snap_name):
+        """Request permission from the store to upload builds of a snap.
+
+        :param snap_series: The `ISnapSeries` in which this snap should be
+            published on the store.
+        :param snap_name: The registered name of this snap on the store.
+        :return: A serialized macaroon appropriate for uploading builds of
+            this snap.
+        """
+
+    def upload(snapbuild):
+        """Upload a snap build to the store.
+
+        :param snapbuild: The `ISnapBuild` to upload.
+        """

=== added file 'lib/lp/snappy/model/snapstoreclient.py'
--- lib/lp/snappy/model/snapstoreclient.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/snapstoreclient.py	2016-05-03 19:57:26 +0000
@@ -0,0 +1,143 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Communication with the snap store."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SnapStoreClient',
+    ]
+
+try:
+    from urllib.parse import quote_plus
+except ImportError:
+    from urllib import quote_plus
+
+from lazr.restful.utils import get_current_browser_request
+import requests
+from requests_toolbelt import MultipartEncoder
+from zope.interface import implementer
+
+from lp.services.config import config
+from lp.services.timeline.requesttimeline import get_request_timeline
+from lp.services.timeout import urlfetch
+from lp.services.webapp.url import urlappend
+from lp.snappy.interfaces.snapstoreclient import (
+    BadRequestPackageUploadResponse,
+    BadUploadResponse,
+    ISnapStoreClient,
+    )
+
+
+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 MacaroonAuth(requests.auth.AuthBase):
+    """Attaches macaroon authentication to a given Request object."""
+
+    def __init__(self, tokens):
+        self.tokens = tokens
+
+    def __call__(self, r):
+        r.headers["Authorization"] = (
+            "Macaroon " +
+            ", ".join('%s="%s"' % (k, v) for k, v in self.tokens.items()))
+        return r
+
+
+@implementer(ISnapStoreClient)
+class SnapStoreClient:
+    """A client for the API provided by the snap store."""
+
+    def requestPackageUpload(self, snap_series, snap_name):
+        assert config.snappy.store_url is not None
+        request_url = urlappend(
+            config.snappy.store_url, "api/2.0/acl/package_upload/")
+        request = get_current_browser_request()
+        timeline_action = get_request_timeline(request).start(
+            "request-snap-upload-macaroon",
+            "%s/%s" % (snap_series.name, snap_name), allow_nested=True)
+        try:
+            response = urlfetch(
+                request_url, method="POST",
+                json={"name": snap_name, "series": snap_series.name})
+            response_data = response.json()
+            if "macaroon" not in response_data:
+                raise BadRequestPackageUploadResponse(response.text)
+            return response_data["macaroon"]
+        except requests.HTTPError as e:
+            raise BadRequestPackageUploadResponse(e.response.text)
+        finally:
+            timeline_action.finish()
+
+    def _uploadFile(self, lfa, lfc):
+        """Upload a single file."""
+        assert config.snappy.store_upload_url is not None
+        unscanned_upload_url = urlappend(
+            config.snappy.store_upload_url, "unscanned-upload/")
+        lfa.open()
+        try:
+            lfa_wrapper = LibraryFileAliasWrapper(lfa)
+            encoder = MultipartEncoder(
+                fields={
+                    "binary": (
+                        "filename", lfa_wrapper, "application/octet-stream"),
+                    })
+            try:
+                response = urlfetch(
+                    unscanned_upload_url, method="POST", data=encoder,
+                    headers={"Content-Type": encoder.content_type})
+                response_data = response.json()
+                if not response_data.get("successful", False):
+                    raise BadUploadResponse(response.text)
+                return {"upload_id": response_data["upload_id"]}
+            except requests.HTTPError as e:
+                raise BadUploadResponse(e.response.text)
+        finally:
+            lfa.close()
+
+    def _uploadApp(self, snap, upload_data):
+        """Create a new store upload based on the uploaded file."""
+        assert config.snappy.store_url is not None
+        assert snap.store_name is not None
+        upload_url = urlappend(
+            config.snappy.store_url,
+            "dev/api/snap-upload/%s/" % quote_plus(snap.store_name))
+        data = {
+            "updown_id": upload_data["upload_id"],
+            "series": snap.store_series.name,
+            }
+        # XXX cjwatson 2016-04-20: handle refresh
+        try:
+            assert snap.store_tokens is not None
+            urlfetch(
+                upload_url, method="POST", data=data,
+                auth=MacaroonAuth(snap.store_tokens))
+        except requests.HTTPError as e:
+            raise BadUploadResponse(e.response.text)
+
+    def upload(self, snapbuild):
+        """See `ISnapStoreClient`."""
+        for _, lfa, lfc in snapbuild.getFiles():
+            upload_data = self._uploadFile(lfa, lfc)
+            self._uploadApp(snapbuild.snap, upload_data)

=== added file 'lib/lp/snappy/tests/test_snapstoreclient.py'
--- lib/lp/snappy/tests/test_snapstoreclient.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_snapstoreclient.py	2016-05-03 19:57:26 +0000
@@ -0,0 +1,183 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for communication with the snap store."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from cgi import FieldStorage
+import io
+import json
+
+from httmock import (
+    all_requests,
+    HTTMock,
+    urlmatch,
+    )
+from requests.utils import parse_dict_header
+from testtools.matchers import (
+    Contains,
+    Equals,
+    Matcher,
+    MatchesDict,
+    MatchesStructure,
+    StartsWith,
+    )
+import transaction
+from zope.component import getUtility
+
+from lp.services.config import config
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.url import urlappend
+from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
+from lp.snappy.interfaces.snapstoreclient import (
+    BadRequestPackageUploadResponse,
+    ISnapStoreClient,
+    )
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class RequestMatches(Matcher):
+    """Matches a request with the specified attributes."""
+
+    def __init__(self, url, auth=None, json_data=None, form_data=None,
+                 **kwargs):
+        self.url = url
+        self.auth = auth
+        self.json_data = json_data
+        self.form_data = form_data
+        self.kwargs = kwargs
+
+    def match(self, request):
+        mismatch = MatchesStructure(url=self.url, **self.kwargs).match(request)
+        if mismatch is not None:
+            return mismatch
+        if self.auth is not None:
+            mismatch = Contains("Authorization").match(request.headers)
+            if mismatch is not None:
+                return mismatch
+            auth_value = request.headers["Authorization"]
+            auth_scheme, auth_params = self.auth
+            mismatch = StartsWith(auth_scheme + " ").match(auth_value)
+            if mismatch is not None:
+                return mismatch
+            mismatch = Equals(auth_params).match(
+                parse_dict_header(auth_value[len(auth_scheme + " "):]))
+            if mismatch is not None:
+                return mismatch
+        if self.json_data is not None:
+            mismatch = Equals(self.json_data).match(json.loads(request.body))
+            if mismatch is not None:
+                return mismatch
+        if self.form_data is not None:
+            if hasattr(request.body, "read"):
+                body = request.body.read()
+            else:
+                body = request.body
+            fs = FieldStorage(
+                fp=io.BytesIO(body),
+                environ={"REQUEST_METHOD": request.method},
+                headers=request.headers)
+            mismatch = MatchesDict(self.form_data).match(fs)
+            if mismatch is not None:
+                return mismatch
+
+
+class TestSnapStoreClient(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestSnapStoreClient, self).setUp()
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+        self.pushConfig(
+            "snappy", store_url="http://sca.example/";,
+            store_upload_url="http://updown.example/";)
+        self.client = getUtility(ISnapStoreClient)
+
+    def test_requestPackageUpload(self):
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {"status_code": 200, "content": {"macaroon": "dummy"}}
+
+        snap_series = self.factory.makeSnapSeries(name="rolling")
+        with HTTMock(handler):
+            macaroon = self.client.requestPackageUpload(
+                snap_series, "test-snap")
+        self.assertThat(self.request, RequestMatches(
+            url=Equals(urlappend(
+                config.snappy.store_url, "api/2.0/acl/package_upload/")),
+            method=Equals("POST"),
+            json_data={"name": "test-snap", "series": "rolling"}))
+        self.assertEqual("dummy", macaroon)
+
+    def test_requestPackageUpload_missing_macaroon(self):
+        @all_requests
+        def handler(url, request):
+            return {"status_code": 200, "content": {}}
+
+        snap_series = self.factory.makeSnapSeries()
+        with HTTMock(handler):
+            self.assertRaises(
+                BadRequestPackageUploadResponse,
+                self.client.requestPackageUpload, snap_series, "test-snap")
+
+    def test_requestPackageUpload_404(self):
+        @all_requests
+        def handler(url, request):
+            return {"status_code": 404}
+
+        snap_series = self.factory.makeSnapSeries()
+        with HTTMock(handler):
+            self.assertRaises(
+                BadRequestPackageUploadResponse,
+                self.client.requestPackageUpload, snap_series, "test-snap")
+
+    def test_upload(self):
+        @urlmatch(path=r".*/unscanned-upload/$")
+        def unscanned_upload_handler(url, request):
+            self.unscanned_upload_request = request
+            return {
+                "status_code": 200,
+                "content": {"successful": True, "upload_id": 1},
+                }
+
+        @urlmatch(path=r".*/snap-upload/.*")
+        def snap_upload_handler(url, request):
+            self.snap_upload_request = request
+            return {"status_code": 202, "content": {"success": True}}
+
+        store_tokens = {"root": "dummy-root", "discharge": "dummy-discharge"}
+        snap = self.factory.makeSnap(
+            store_upload=True,
+            store_series=self.factory.makeSnapSeries(name="rolling"),
+            store_name="test-snap", store_tokens=store_tokens)
+        snapbuild = self.factory.makeSnapBuild(snap=snap)
+        lfa = self.factory.makeLibraryFileAlias(content="dummy snap content")
+        self.factory.makeSnapFile(snapbuild=snapbuild, libraryfile=lfa)
+        transaction.commit()
+        with HTTMock(unscanned_upload_handler, snap_upload_handler):
+            self.client.upload(snapbuild)
+        self.assertThat(self.unscanned_upload_request, RequestMatches(
+            url=Equals(urlappend(
+                config.snappy.store_upload_url, "unscanned-upload/")),
+            method=Equals("POST"),
+            form_data={
+                "binary": MatchesStructure.byEquality(
+                    name="binary", filename="filename",
+                    value="dummy snap content",
+                    type="application/octet-stream",
+                    )}))
+        self.assertThat(self.snap_upload_request, RequestMatches(
+            url=Equals(urlappend(
+                config.snappy.store_url, "dev/api/snap-upload/test-snap/")),
+            method=Equals("POST"), auth=("Macaroon", store_tokens),
+            form_data={
+                "updown_id": MatchesStructure.byEquality(
+                    name="updown_id", value="1"),
+                "series": MatchesStructure.byEquality(
+                    name="series", value="rolling")}))

=== modified file 'setup.py'
--- setup.py	2016-03-16 02:08:40 +0000
+++ setup.py	2016-05-03 19:57:26 +0000
@@ -77,13 +77,14 @@
         'paramiko',
         'pgbouncer',
         'psycopg2',
-        'python-memcached',
         'pyasn1',
         'pystache',
+        'python-memcached',
         'python-openid',
         'pytz',
         'rabbitfixture',
         'requests',
+        'requests-toolbelt',
         's4',
         'setproctitle',
         'setuptools',

=== modified file 'versions.cfg'
--- versions.cfg	2016-03-16 02:08:40 +0000
+++ versions.cfg	2016-05-03 19:57:26 +0000
@@ -119,6 +119,7 @@
 PyYAML = 3.10
 rabbitfixture = 0.3.6
 requests = 2.7.0
+requests-toolbelt = 0.6.0
 s4 = 0.1.2
 setproctitle = 1.1.7
 setuptools-git = 1.0


Follow ups