Push metadata to the Store. #1634

Merged
merged 11 commits into from Nov 27, 2017
View
@@ -215,3 +215,19 @@
2. Make sure Snapcraft works by running `snapcraft init` followed by `snapcraft`.
3. Follow HACKING.md to install using `pip` while using --editable.
4. Repeat step 2.
+
+
+# Test push metadata with conflicts
+
+1. 'snapcraft build' a simple snap
+2. Do a simple 'snapcraft push SNAP'
+3. Go to the Web and change snap's description
+4. Change the snap's description in the YAML file to something different than you put in the Web
+5. Try to update snap's metadata doing `snapcraft push-metadata SNAP`
+
+ * Check that it should error with "conflict" on the description field
+
+6. Force the update doing `snapcraft push-metadata SNAP --force`
+
+ * Check that it should end ok
+ * Check in the Web that the description is now what the YAML says
View
@@ -414,6 +414,7 @@ def _get_version():
list_registered,
login,
push,
+ push_metadata,
register,
register_key,
release,
View
@@ -381,6 +381,33 @@ def sign_build(snap_filename, key_name=None, local=False):
'Build assertion {} pushed to the Store.'.format(snap_build_path))
+def push_metadata(snap_filename, force):
+ """Push only the metadata to the server.
+
+ If force=True it will force the local metadata into the Store,
+ ignoring any possible conflict.
+ """
+ logger.debug("Pushing metadata to the Store (force=%s)", force)
+
+ # get the metadata from the snap
+ snap_yaml = _get_data_from_snap_file(snap_filename)
+ metadata = {
+ 'summary': snap_yaml['summary'],
+ 'description': snap_yaml['description'],
+ }
+
+ # other snap info
+ snap_name = snap_yaml['name']
+
+ # hit the server
+ store = storeapi.StoreClient()
+ with _requires_login():
+ store.push_precheck(snap_name)
+ store.push_metadata(snap_name, metadata, force)
+
+ logger.info("The metadata has been pushed")
+
+
def push(snap_filename, release_channels=None):
"""Push a snap_filename to the store.
@@ -423,16 +450,12 @@ def push(snap_filename, release_channels=None):
else:
result = _push_snap(snap_name, snap_filename)
- # This is workaround until LP: #1599875 is solved
- if 'revision' in result:
- logger.info('Revision {!r} of {!r} created.'.format(
- result['revision'], snap_name))
+ logger.info('Revision {!r} of {!r} created.'.format(
+ result['revision'], snap_name))
- snap_cache.cache(snap_filename=snap_filename)
- snap_cache.prune(deb_arch=arch,
- keep_hash=calculate_sha3_384(snap_filename))
- else:
- logger.info('Pushing {!r}'.format(snap_name))
+ snap_cache.cache(snap_filename=snap_filename)
+ snap_cache.prune(deb_arch=arch,
+ keep_hash=calculate_sha3_384(snap_filename))
if release_channels:
release(snap_name, result['revision'], release_channels)
View
@@ -1,6 +1,6 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
-# Copyright (C) 2016-2017 Canonical Ltd
+# Copyright 2016-2017 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
@@ -91,6 +91,7 @@ def register(snap_name, private):
dir_okay=False))
def push(snap_file, release):
"""Push <snap-file> to the store.
+
By passing --release with a comma separated list of channels the snap would
be released to the selected channels if the store review passes for this
<snap-file>.
@@ -118,6 +119,29 @@ def push(snap_file, release):
snapcraft.push(snap_file, channel_list)
+@storecli.command('push-metadata')
+@click.option('--force', is_flag=True,
+ help="Force metadata update to override any possible conflict")
+@click.argument('snap-file', metavar='<snap-file>',
+ type=click.Path(exists=True,
+ readable=True,
+ resolve_path=True,
+ dir_okay=False))
+def push_metadata(snap_file, force):
+ """Push metadata from <snap-file> to the store.
+
+ If --force is given, it will it will force the local metadata into the
+ Store, ignoring any possible conflict.
+
+ \b
+ Examples:
+ snapcraft push-metadata my-snap_0.1_amd64.snap
+ snapcraft push-metadata my-snap_0.1_amd64.snap --force
+ """
+ click.echo('Pushing metadata from {}'.format(os.path.basename(snap_file)))
+ snapcraft.push_metadata(snap_file, force)
+
+
@storecli.command()
@click.argument('snap-name', metavar='<snap-name>')
@click.argument('revision', metavar='<revision>')
@@ -1,6 +1,6 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
-# Copyright (C) 2016-2017 Canonical Ltd
+# Copyright 2016-2017 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
@@ -364,6 +364,18 @@ def get_assertion(self, snap_id, endpoint):
def sign_developer_agreement(self, latest_tos_accepted=False):
return self.sca.sign_developer_agreement(latest_tos_accepted)
+ def push_metadata(self, snap_name, metadata, force):
+ """Push the metadata to the server."""
+ account_info = self.get_account_information()
+ series = constants.DEFAULT_SERIES
+ try:
+ snap_id = account_info['snaps'][series][snap_name]['snap-id']
+ except KeyError:
+ raise errors.SnapNotFoundError(snap_name, series=series)
+
+ return self._refresh_if_necessary(
+ self.sca.push_metadata, snap_id, snap_name, metadata, force)
+
class SSOClient(Client):
"""The Single Sign On server deals with authentication.
@@ -609,6 +621,21 @@ def snap_push_metadata(self, snap_name, updown_data,
return StatusTracker(response.json()['status_details_url'])
+ def push_metadata(self, snap_id, snap_name, metadata, force):
+ """Push the metadata to SCA."""
+ url = 'snaps/' + snap_id + '/metadata'
+ headers = {
+ 'Authorization': _macaroon_auth(self.conf),
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ }
+ method = 'PUT' if force else 'POST'
+ response = self.request(
+ method, url, data=json.dumps(metadata), headers=headers)
+
+ if not response.ok:
+ raise errors.StoreMetadataError(snap_name, response, metadata)
+
def snap_release(self, snap_name, revision, channels, delta_format=None):
data = {
'name': snap_name,
@@ -1,6 +1,6 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
-# Copyright (C) 2016-2017 Canonical Ltd
+# Copyright 2016-2017 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
@@ -379,6 +379,47 @@ def __fmt_error_unknown(self, response):
return fmt
+class StoreMetadataError(StoreError):
+
+ __FMT_NOT_FOUND = (
+ "Sorry, updating the information on the store has failed, first run "
+ "`snapcraft register {snap_name}` and then "
+ "`snapcraft push <snap-file>`."
+ )
+
+ fmt = 'Received {status_code!r}: {text!r}'
+
+ def __init__(self, snap_name, response, metadata):
+ try:
+ response_json = response.json()
+ except (AttributeError, JSONDecodeError):
+ response_json = {}
+
+ if response.status_code == 404:
+ self.fmt = self.__FMT_NOT_FOUND
+ elif response.status_code == 409:
+ conflicts = [(error['extra']['name'], error)
+ for error in response_json['error_list']
+ if error['code'] == 'conflict']
+ parts = ["Metadata not pushed!"]
+ for field_name, error in sorted(conflicts):
+ sent = metadata.get(field_name)
+ parts.extend((
+ "Conflict in {!r} field:".format(field_name),
+ " In snapcraft.yaml: {!r}".format(sent),
+ " In the Store: {!r}".format(error['message']),
+ ))
+ parts.append(
+ "You can repeat the push-metadata command with "
+ "--force to force the local values into the Store")
+ self.fmt = "\n".join(parts)
+ elif 'error_list' in response_json:
+ response_json['text'] = response_json['error_list'][0]['message']
+
+ super().__init__(snap_name=snap_name, status_code=response.status_code,
+ **response_json)
+
+
class StoreValidationError(StoreError):
fmt = 'Received error {status_code!r}: {text!r}'
@@ -1,6 +1,6 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
-# Copyright (C) 2016, 2017 Canonical Ltd
+# Copyright 2016, 2017 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
@@ -88,6 +88,14 @@ def configure(self, configurator):
request_method='POST')
configurator.add_view(self.agreement, route_name='agreement')
+ configurator.add_route(
+ 'snap_metadata_post',
+ urllib.parse.urljoin(
+ self._DEV_API_PATH, 'snaps/{snap_id}/metadata'),
+ request_method='POST')
+ configurator.add_view(
+ self.snap_metadata, route_name='snap_metadata_post')
+
# GET
configurator.add_route(
'details',
@@ -146,6 +154,14 @@ def configure(self, configurator):
configurator.add_view(
self.put_snap_developers, route_name='put_snap_developers')
+ configurator.add_route(
+ 'snap_metadata_put',
+ urllib.parse.urljoin(
+ self._DEV_API_PATH, 'snaps/{snap_id}/metadata'),
+ request_method='PUT')
+ configurator.add_view(
+ self.snap_metadata, route_name='snap_metadata_put')
+
def _refresh_error(self):
error = {
'code': 'macaroon-permission-required',
@@ -575,6 +591,44 @@ def agreement(self, request):
return response.Response(
payload, response_code, [('Content-Type', content_type)])
+ def snap_metadata(self, request):
+ logger.debug('Handling metadata request')
+ if 'invalid' in request.json_body:
+ err = {'error_list': [{
+ 'message': 'Invalid field: invalid',
+ 'code': 'invalid-request',
+ }]}
+ payload = json.dumps(err).encode('utf8')
+ response_code = 400
+ content_type = 'application/json'
+ elif any('conflict' in field_name for field_name in request.json_body):
+ # conflicts!
+ if request.method == 'PUT':
+ # update anyway
+ payload = b''
+ response_code = 200
+ content_type = 'text/plain'
+ else:
+ # POST, return error
+ error_list = []
+ for name, value in request.json_body.items():
+ error_list.append({
+ 'message': value + '-changed',
+ 'code': 'conflict',
+ 'extra': {'name': name},
+ })
+ payload = json.dumps({'error_list': error_list}).encode('utf8')
+ response_code = 409
+ content_type = 'application/json'
+ else:
+ # all fine by default
+ payload = b''
+ response_code = 200
+ content_type = 'text/plain'
+
+ return response.Response(
+ payload, response_code, [('Content-Type', content_type)])
+
# GET
def details(self, request):
@@ -0,0 +1,61 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import subprocess
+
+from testtools.matchers import Contains, FileExists
+
+from snapcraft.tests import integration
+
+
+class PushMetadataTestCase(integration.StoreTestCase):
+
+ def test_without_login(self):
+ self.run_snapcraft('snap', 'basic')
+ snap_file_path = 'basic_0.1_{}.snap'.format('all')
+ self.assertThat(snap_file_path, FileExists())
+
+ error = self.assertRaises(
+ subprocess.CalledProcessError,
+ self.run_snapcraft, ['push', snap_file_path])
+ self.assertIn('No valid credentials found. Have you run "snapcraft '
+ 'login"?', str(error.output))
+
+ def test_with_login(self):
+ # Make a snap
+ self.addCleanup(self.logout)
+ self.login()
+
+ # Change to a random name and version.
+ name = self.get_unique_name()
+ version = self.get_unique_version()
+ self.copy_project_to_cwd('basic')
+ self.update_name_and_version(name, version)
+
+ self.run_snapcraft('snap')
+
+ # Register the snap
+ self.register(name)
+
+ # Push the snap
+ snap_file_path = '{}_{}_{}.snap'.format(name, version, 'all')
+ self.assertThat(os.path.join(snap_file_path), FileExists())
+ output = self.run_snapcraft(['push-metadata', snap_file_path])
+ expected = "Pushing metadata to the Store (force=False)"
+ self.assertThat(output, Contains(expected))
+ expected = "The metadata has been pushed"
+ self.assertThat(output, Contains(expected))
Oops, something went wrong.