← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-channels-store-client into lp:launchpad with lp:~cjwatson/launchpad/snap-auto-build-ui as a prerequisite.

Commit message:
Add model and store client support for automatically releasing snap packages.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1597819 in Launchpad itself: "Add option to automatically release snap packages to channels after upload"
  https://bugs.launchpad.net/launchpad/+bug/1597819

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

Add model and store client support for automatically releasing snap packages.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-channels-store-client into lp:launchpad.
=== modified file 'configs/development/launchpad-lazr.conf'
--- configs/development/launchpad-lazr.conf	2016-05-18 00:33:18 +0000
+++ configs/development/launchpad-lazr.conf	2016-06-30 16:26:55 +0000
@@ -190,6 +190,7 @@
 builder_proxy_auth_api_endpoint: http://snap-proxy.launchpad.dev:8080/tokens
 builder_proxy_host: snap-proxy.launchpad.dev
 builder_proxy_port: 3128
+store_search_url: https://search.apps.ubuntu.com/
 tools_source: deb http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu %(series)s main
 
 [txlongpoll]

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2016-06-20 20:18:11 +0000
+++ lib/lp/services/config/schema-lazr.conf	2016-06-30 16:26:55 +0000
@@ -1804,6 +1804,9 @@
 # The store's upload URL endpoint.
 store_upload_url: none
 
+# The store's search URL endpoint.
+store_search_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/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2016-06-20 20:32:36 +0000
+++ lib/lp/snappy/interfaces/snap.py	2016-06-30 16:26:55 +0000
@@ -435,6 +435,13 @@
             "Serialized secrets issued by the store and the login service to "
             "authorize uploads of this snap package."))
 
+    store_channels = List(
+        value_type=TextLine(), title=_("Store channels"),
+        required=False, readonly=False,
+        description=_(
+            "Channels to which to release this snap package after "
+            "uploading it to the store."))
+
 
 class ISnapAdminAttributes(Interface):
     """`ISnap` attributes that can be edited by admins.

=== modified file 'lib/lp/snappy/interfaces/snapstoreclient.py'
--- lib/lp/snappy/interfaces/snapstoreclient.py	2016-06-21 14:51:06 +0000
+++ lib/lp/snappy/interfaces/snapstoreclient.py	2016-06-30 16:26:55 +0000
@@ -8,11 +8,14 @@
 __metaclass__ = type
 __all__ = [
     'BadRefreshResponse',
+    'BadReleaseResponse',
     'BadRequestPackageUploadResponse',
     'BadScanStatusResponse',
+    'BadSearchResponse',
     'BadUploadResponse',
     'ISnapStoreClient',
     'NeedsRefreshResponse',
+    'ReleaseFailedResponse',
     'ScanFailedResponse',
     'UnauthorizedUploadResponse',
     'UploadNotScannedYetResponse',
@@ -53,6 +56,18 @@
     pass
 
 
+class BadSearchResponse(Exception):
+    pass
+
+
+class BadReleaseResponse(Exception):
+    pass
+
+
+class ReleaseFailedResponse(Exception):
+    pass
+
+
 class ISnapStoreClient(Interface):
     """Interface for the API provided by the snap store."""
 
@@ -91,6 +106,26 @@
             scanned the upload.
         :raises BadScanStatusResponse: if the store failed to scan the
             upload.
-        :return: A URL on the store with further information about this
-            upload.
+        :return: A tuple of (`url`, `revision`), where `url` is a URL on the
+            store with further information about this upload, and `revision`
+            is the store revision number for the upload or None.
+        """
+
+    def listChannels():
+        """Fetch the current list of channels from the store.
+
+        :raises BadSearchResponse: if the attempt to fetch the list of
+            channels from the store fails.
+        :return: A list of dictionaries, one per channel, each of which
+            contains at least "name" and "display_name" keys.
+        """
+
+    def release(snapbuild, revision):
+        """Tell the store to release a snap build to specified channels.
+
+        :param snapbuild: The `ISnapBuild` to release.
+        :param revision: The revision returned by the store when uploading
+            the build.
+        :raises BadReleaseResponse: if the store failed to release the
+            build.
         """

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2016-06-30 16:09:25 +0000
+++ lib/lp/snappy/model/snap.py	2016-06-30 16:26:55 +0000
@@ -187,12 +187,14 @@
 
     store_secrets = JSON('store_secrets', allow_none=True)
 
+    store_channels = JSON('store_channels', allow_none=True)
+
     def __init__(self, registrant, owner, distro_series, name,
                  description=None, branch=None, git_ref=None, auto_build=False,
                  auto_build_archive=None, auto_build_pocket=None,
                  require_virtualized=True, date_created=DEFAULT,
                  private=False, store_upload=False, store_series=None,
-                 store_name=None, store_secrets=None):
+                 store_name=None, store_secrets=None, store_channels=None):
         """Construct a `Snap`."""
         if not getFeatureFlag(SNAP_FEATURE_FLAG):
             raise SnapFeatureDisabled
@@ -216,6 +218,7 @@
         self.store_series = store_series
         self.store_name = store_name
         self.store_secrets = store_secrets
+        self.store_channels = store_channels
 
     @property
     def valid_webhook_event_types(self):
@@ -511,7 +514,7 @@
             auto_build_archive=None, auto_build_pocket=None,
             require_virtualized=True, processors=None, date_created=DEFAULT,
             private=False, store_upload=False, store_series=None,
-            store_name=None, store_secrets=None):
+            store_name=None, store_secrets=None, store_channels=None):
         """See `ISnapSet`."""
         if not registrant.inTeam(owner):
             if owner.is_team:
@@ -540,7 +543,7 @@
             require_virtualized=require_virtualized, date_created=date_created,
             private=private, store_upload=store_upload,
             store_series=store_series, store_name=store_name,
-            store_secrets=store_secrets)
+            store_secrets=store_secrets, store_channels=store_channels)
         store.add(snap)
 
         if processors is None:

=== modified file 'lib/lp/snappy/model/snapstoreclient.py'
--- lib/lp/snappy/model/snapstoreclient.py	2016-06-27 13:19:15 +0000
+++ lib/lp/snappy/model/snapstoreclient.py	2016-06-30 16:26:55 +0000
@@ -10,25 +10,36 @@
     'SnapStoreClient',
     ]
 
+import json
 import string
+import time
+from urlparse import urlsplit
 
 from lazr.restful.utils import get_current_browser_request
 from pymacaroons import Macaroon
 import requests
 from requests_toolbelt import MultipartEncoder
+from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
 from lp.services.config import config
+from lp.services.features import getFeatureFlag
+from lp.services.memcache.interfaces import IMemcacheClient
+from lp.services.scripts import log
 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 (
     BadRefreshResponse,
+    BadReleaseResponse,
     BadRequestPackageUploadResponse,
     BadScanStatusResponse,
+    BadSearchResponse,
     BadUploadResponse,
     ISnapStoreClient,
     NeedsRefreshResponse,
+    ReleaseFailedResponse,
     ScanFailedResponse,
     UnauthorizedUploadResponse,
     UploadNotScannedYetResponse,
@@ -99,10 +110,28 @@
         return r
 
 
+# Hardcoded fallback.
+_default_store_channels = [
+    {"name": "candidate", "display_name": "Candidate"},
+    {"name": "edge", "display_name": "Edge"},
+    {"name": "beta", "display_name": "Beta"},
+    {"name": "stable", "display_name": "Stable"},
+    ]
+
+
 @implementer(ISnapStoreClient)
 class SnapStoreClient:
     """A client for the API provided by the snap store."""
 
+    @staticmethod
+    def _getTimeline():
+        # XXX cjwatson 2016-06-29: This can be simplified once jobs have
+        # timeline support.
+        request = get_current_browser_request()
+        if request is None:
+            return None
+        return get_request_timeline(request)
+
     def requestPackageUploadPermission(self, snappy_series, snap_name):
         assert config.snappy.store_url is not None
         request_url = urlappend(config.snappy.store_url, "dev/api/acl/")
@@ -203,6 +232,7 @@
 
     @classmethod
     def refreshDischargeMacaroon(cls, snap):
+        """See `ISnapStoreClient`."""
         assert config.launchpad.openid_provider_root is not None
         assert snap.store_secrets is not None
         refresh_url = urlappend(
@@ -222,6 +252,7 @@
 
     @classmethod
     def checkStatus(cls, status_url):
+        """See `ISnapStoreClient`."""
         try:
             response = urlfetch(status_url)
             response_data = response.json()
@@ -232,7 +263,9 @@
                 elif not response_data["application_url"]:
                     raise ScanFailedResponse(response_data["message"])
                 else:
-                    return response_data["application_url"]
+                    return (
+                        response_data["application_url"],
+                        response_data["revision"])
             else:
                 # New status format.
                 if not response_data["processed"]:
@@ -241,7 +274,88 @@
                     error_message = "\n".join(
                         error["message"] for error in response_data["errors"])
                     raise ScanFailedResponse(error_message)
+                elif not response_data["can_release"]:
+                    return response_data["url"], None
                 else:
-                    return response_data["url"]
+                    return response_data["url"], response_data["revision"]
         except requests.HTTPError as e:
             raise BadScanStatusResponse(e.args[0])
+
+    @classmethod
+    def listChannels(cls):
+        """See `ISnapStoreClient`."""
+        if config.snappy.store_search_url is None:
+            return _default_store_channels
+        channels = None
+        memcache_client = getUtility(IMemcacheClient)
+        search_host = urlsplit(config.snappy.store_search_url).hostname
+        memcache_key = ("%s:channels" % search_host).encode("UTF-8")
+        cached_channels = memcache_client.get(memcache_key)
+        if cached_channels is not None:
+            try:
+                channels = json.loads(cached_channels)
+            except Exception:
+                log.exception(
+                    "Cannot load cached channels for %s; deleting" %
+                    search_host)
+                memcache_client.delete(memcache_key)
+        if (channels is None and
+                not getFeatureFlag(u"snap.disable_channel_search")):
+            path = "api/v1/channels"
+            timeline = cls._getTimeline()
+            if timeline is not None:
+                action = timeline.start("store-search-get", "/" + path)
+            channels_url = urlappend(config.snappy.store_search_url, path)
+            try:
+                response = urlfetch(
+                    channels_url, headers={"Accept": "application/hal+json"})
+            except requests.HTTPError as e:
+                raise BadSearchResponse(e.args[0])
+            finally:
+                if timeline is not None:
+                    action.finish()
+            channels = response.json().get("_embedded", {}).get(
+                "clickindex:channel", [])
+            expire_time = time.time() + 60 * 60 * 24
+            memcache_client.set(
+                memcache_key, json.dumps(channels), expire_time)
+        if channels is None:
+            channels = _default_store_channels
+        return channels
+
+    @classmethod
+    def release(cls, snapbuild, revision):
+        """See `ISnapStoreClient`."""
+        assert config.snappy.store_url is not None
+        snap = snapbuild.snap
+        assert snap.store_name is not None
+        assert snap.store_series is not None
+        assert snap.store_channels
+        release_url = urlappend(
+            config.snappy.store_url, "dev/api/snap-release/")
+        data = {
+            "name": snap.store_name,
+            "revision": revision,
+            # The security proxy is useless and breaks JSON serialisation.
+            "channels": removeSecurityProxy(snap.store_channels),
+            "series": snap.store_series.name,
+            }
+        # XXX cjwatson 2016-06-28: This should add timeline information, but
+        # that's currently difficult in jobs.
+        try:
+            assert snap.store_secrets is not None
+            urlfetch(
+                release_url, method="POST", json=data,
+                auth=MacaroonAuth(
+                    snap.store_secrets["root"],
+                    snap.store_secrets["discharge"]))
+        except requests.HTTPError as e:
+            if e.response is not None:
+                error = None
+                try:
+                    error = e.response.json()["errors"]
+                except Exception:
+                    pass
+                if error is not None:
+                    raise ReleaseFailedResponse(error)
+            raise BadReleaseResponse(e.args[0])

=== modified file 'lib/lp/snappy/tests/test_snapstoreclient.py'
--- lib/lp/snappy/tests/test_snapstoreclient.py	2016-06-27 13:19:15 +0000
+++ lib/lp/snappy/tests/test_snapstoreclient.py	2016-06-30 16:26:55 +0000
@@ -39,12 +39,16 @@
 from zope.component import getUtility
 
 from lp.services.features.testing import FeatureFixture
+from lp.services.memcache.interfaces import IMemcacheClient
 from lp.services.timeline.requesttimeline import get_request_timeline
 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
 from lp.snappy.interfaces.snapstoreclient import (
+    BadReleaseResponse,
     BadRequestPackageUploadResponse,
     BadScanStatusResponse,
+    BadSearchResponse,
     ISnapStoreClient,
+    ReleaseFailedResponse,
     ScanFailedResponse,
     UnauthorizedUploadResponse,
     UploadNotScannedYetResponse,
@@ -169,7 +173,8 @@
         self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
         self.pushConfig(
             "snappy", store_url="http://sca.example/";,
-            store_upload_url="http://updown.example/";)
+            store_upload_url="http://updown.example/";,
+            store_search_url="http://search.example/";)
         self.pushConfig(
             "launchpad", openid_provider_root="http://sso.example/";)
         self.client = getUtility(ISnapStoreClient)
@@ -225,6 +230,22 @@
             "content": {"discharge_macaroon": new_macaroon.serialize()},
             }
 
+    @urlmatch(path=r".*/snap-release/$")
+    def _snap_release_handler(self, url, request):
+        self.snap_release_request = request
+        return {
+            "status_code": 200,
+            "content": {
+                "success": True,
+                "channel_map": [
+                    {"channel": "stable", "info": "specific",
+                     "version": "1.0", "revision": 1},
+                    {"channel": "edge", "info": "specific",
+                     "version": "1.0", "revision": 1},
+                    ],
+                "opened_channels": ["stable", "edge"],
+                }}
+
     def test_requestPackageUploadPermission(self):
         @all_requests
         def handler(url, request):
@@ -452,7 +473,7 @@
         status_url = "http://sca.example/dev/api/click-scan-complete/updown/1/";
         with HTTMock(handler):
             self.assertEqual(
-                "http://sca.example/dev/click-apps/1/";,
+                ("http://sca.example/dev/click-apps/1/";, 1),
                 self.client.checkStatus(status_url))
 
     def test_checkStatus_new_pending(self):
@@ -525,7 +546,7 @@
         status_url = "http://sca.example/dev/api/snaps/1/builds/1/status";
         with HTTMock(handler):
             self.assertEqual(
-                "http://sca.example/dev/click-apps/1/rev/1/";,
+                ("http://sca.example/dev/click-apps/1/rev/1/";, 1),
                 self.client.checkStatus(status_url))
 
     def test_checkStatus_404(self):
@@ -538,3 +559,121 @@
             self.assertRaisesWithContent(
                 BadScanStatusResponse, b"404 Client Error: Not found",
                 self.client.checkStatus, status_url)
+
+    def test_listChannels(self):
+        expected_channels = [
+            {"name": "stable", "display_name": "Stable"},
+            {"name": "edge", "display_name": "Edge"},
+            ]
+
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {
+                "status_code": 200,
+                "content": {
+                    "_embedded": {"clickindex:channel": expected_channels}}}
+
+        memcache_key = "search.example:channels".encode("UTF-8")
+        try:
+            with HTTMock(handler):
+                self.assertEqual(expected_channels, self.client.listChannels())
+            self.assertThat(self.request, RequestMatches(
+                url=Equals("http://search.example/api/v1/channels";),
+                method=Equals("GET"),
+                headers=ContainsDict(
+                    {"Accept": Equals("application/hal+json")})))
+            self.assertEqual(
+                expected_channels,
+                json.loads(getUtility(IMemcacheClient).get(memcache_key)))
+            self.request = None
+            with HTTMock(handler):
+                self.assertEqual(expected_channels, self.client.listChannels())
+            self.assertIsNone(self.request)
+        finally:
+            getUtility(IMemcacheClient).delete(memcache_key)
+
+    def test_listChannels_404(self):
+        @all_requests
+        def handler(url, request):
+            return {"status_code": 404, "reason": b"Not found"}
+
+        with HTTMock(handler):
+            self.assertRaisesWithContent(
+                BadSearchResponse, b"404 Client Error: Not found",
+                self.client.listChannels)
+
+    def test_listChannels_disable_search(self):
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {"status_code": 404, "reason": b"Not found"}
+
+        self.useFixture(
+            FeatureFixture({u"snap.disable_channel_search": u"on"}))
+        expected_channels = [
+            {"name": "candidate", "display_name": "Candidate"},
+            {"name": "edge", "display_name": "Edge"},
+            {"name": "beta", "display_name": "Beta"},
+            {"name": "stable", "display_name": "Stable"},
+            ]
+        self.request = None
+        with HTTMock(handler):
+            self.assertEqual(expected_channels, self.client.listChannels())
+        self.assertIsNone(self.request)
+        memcache_key = "search.example:channels".encode("UTF-8")
+        self.assertIsNone(getUtility(IMemcacheClient).get(memcache_key))
+
+    def test_release(self):
+        snap = self.factory.makeSnap(
+            store_upload=True,
+            store_series=self.factory.makeSnappySeries(name="rolling"),
+            store_name="test-snap", store_secrets=self._make_store_secrets(),
+            store_channels=["stable", "edge"])
+        snapbuild = self.factory.makeSnapBuild(snap=snap)
+        with HTTMock(self._snap_release_handler):
+            self.client.release(snapbuild, 1)
+        self.assertThat(self.snap_release_request, RequestMatches(
+            url=Equals("http://sca.example/dev/api/snap-release/";),
+            method=Equals("POST"),
+            headers=ContainsDict({"Content-Type": Equals("application/json")}),
+            auth=("Macaroon", MacaroonsVerify(self.root_key)),
+            json_data={
+                "name": "test-snap", "revision": 1,
+                "channels": ["stable", "edge"], "series": "rolling",
+                }))
+
+    def test_release_error(self):
+        @urlmatch(path=r".*/snap-release/$")
+        def handler(url, request):
+            return {
+                "status_code": 503,
+                "content": {"success": False, "errors": "Failed to publish"},
+                }
+
+        snap = self.factory.makeSnap(
+            store_upload=True,
+            store_series=self.factory.makeSnappySeries(name="rolling"),
+            store_name="test-snap", store_secrets=self._make_store_secrets(),
+            store_channels=["stable", "edge"])
+        snapbuild = self.factory.makeSnapBuild(snap=snap)
+        with HTTMock(handler):
+            self.assertRaisesWithContent(
+                ReleaseFailedResponse, "Failed to publish",
+                self.client.release, snapbuild, 1)
+
+    def test_release_404(self):
+        @urlmatch(path=r".*/snap-release/$")
+        def handler(url, request):
+            return {"status_code": 404, "reason": b"Not found"}
+
+        snap = self.factory.makeSnap(
+            store_upload=True,
+            store_series=self.factory.makeSnappySeries(name="rolling"),
+            store_name="test-snap", store_secrets=self._make_store_secrets(),
+            store_channels=["stable", "edge"])
+        snapbuild = self.factory.makeSnapBuild(snap=snap)
+        with HTTMock(handler):
+            self.assertRaisesWithContent(
+                BadReleaseResponse, b"404 Client Error: Not found",
+                self.client.release, snapbuild, 1)

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2016-06-20 20:18:11 +0000
+++ lib/lp/testing/factory.py	2016-06-30 16:26:55 +0000
@@ -4634,7 +4634,8 @@
                  auto_build_archive=None, auto_build_pocket=None,
                  is_stale=None, require_virtualized=True, processors=None,
                  date_created=DEFAULT, private=False, store_upload=False,
-                 store_series=None, store_name=None, store_secrets=None):
+                 store_series=None, store_name=None, store_secrets=None,
+                 store_channels=None):
         """Make a new Snap."""
         if registrant is None:
             registrant = self.makePerson()
@@ -4659,7 +4660,8 @@
             auto_build=auto_build, auto_build_archive=auto_build_archive,
             auto_build_pocket=auto_build_pocket, private=private,
             store_upload=store_upload, store_series=store_series,
-            store_name=store_name, store_secrets=store_secrets)
+            store_name=store_name, store_secrets=store_secrets,
+            store_channels=store_channels)
         if is_stale is not None:
             removeSecurityProxy(snap).is_stale = is_stale
         IStore(snap).flush()


Follow ups