Merge lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers into lp:snappy-ecosystem-tests

Proposed by Heber Parrucci
Status: Superseded
Proposed branch: lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers
Merge into: lp:snappy-ecosystem-tests
Diff against target: 850 lines (+836/-0)
3 files modified
tests/helpers/store_rest_apis.py (+726/-0)
tests/helpers/test_base.py (+29/-0)
tests/utils/fixture_setup.py (+81/-0)
To merge this branch: bzr merge lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers
Reviewer Review Type Date Requested Status
Santiago Baldassin (community) Needs Fixing
platform-qa-bot continuous-integration Needs Fixing
Omer Akram (community) Needs Fixing
Review via email: mp+316354@code.launchpad.net

This proposal has been superseded by a proposal from 2017-02-14.

Commit message

Adding helpers for Store RESTful APIs.
Also includes a first version of ecosystem test base clase and fixture setup.

Description of the change

This change adds helpers for Store RESTful APIs, a first version of snappy ecosystem test base class and a fixture setup

To post a comment you must log in.
6. By Heber Parrucci

adding default json headers in base client

Revision history for this message
I Ahmad (iahmad) wrote :

How about using it as it is from snapcraft/storeapi as it is, instead of making a copy? that way we won't have to catchup the changes upstream.

Also if we have an example test case along with the merge then it will become convenient to test the base and other utility classes.

Otherwise LGTM

Revision history for this message
Omer Akram (om26er) wrote :

Thanks for working on this, I was not able to test the code but wrote some inline comments and suggestions.

review: Needs Fixing
Revision history for this message
Omer Akram (om26er) wrote :

We are missing docstrings, so it would also be helpful if you could add docstrings for the methods.

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Santiago Baldassin (sbaldassin) wrote :

Looks good in general. I just think that we should rethink the way the different clients are represented here. So far it is a little bit confussing

review: Needs Fixing
Revision history for this message
Heber Parrucci (heber013) wrote :

Reply inline. I will address other comments once we agree in this one, because it would make other comments as not valid depending on which approach we take: use snapcraft upstream code or not.

7. By Heber Parrucci

Fixing comments on code review.
Fixing pylint issues.
Addind a simple test for login to Store REST API

8. By Heber Parrucci

fixing error in requirements.txt

9. By Heber Parrucci

removing duplicated dependencies in requirements.txt

10. By Heber Parrucci

changing dict initialization sintax according to PEP 448

11. By Heber Parrucci

Merge from parent branch and fixing pylint to met max-line-length=80

12. By Heber Parrucci

deleting file not needed

13. By Heber Parrucci

changing dict initialization

14. By Heber Parrucci

chaning dict initialization until jenkins slave is updated

15. By Heber Parrucci

Updating README.rst with user credentials instructions

16. By Heber Parrucci

merge from trunk

17. By Heber Parrucci

Updating README.rst to make storing credentials section more clear.

18. By Heber Parrucci

renaming class Store and adding a better docstring

19. By Heber Parrucci

fixing pylint

20. By Heber Parrucci

updating license year in headers

21. By Heber Parrucci

merge from trunk

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'tests/helpers/store_rest_apis.py'
2--- tests/helpers/store_rest_apis.py 1970-01-01 00:00:00 +0000
3+++ tests/helpers/store_rest_apis.py 2017-02-03 18:09:41 +0000
4@@ -0,0 +1,726 @@
5+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
6+#
7+# Copyright (C) 2017 Canonical Ltd
8+#
9+# This program is free software: you can redistribute it and/or modify
10+# it under the terms of the GNU General Public License version 3 as
11+# published by the Free Software Foundation.
12+#
13+# This program is distributed in the hope that it will be useful,
14+# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+# GNU General Public License for more details.
17+#
18+# You should have received a copy of the GNU General Public License
19+# along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
21+import contextlib
22+import hashlib
23+import itertools
24+import json
25+import logging
26+import os
27+import urllib.parse
28+from time import sleep
29+from threading import Thread
30+from queue import Queue
31+
32+from progressbar import (
33+ AnimatedMarker,
34+ ProgressBar,
35+ UnknownLength,
36+)
37+import pymacaroons
38+import requests
39+from simplejson.scanner import JSONDecodeError
40+
41+import snapcraft
42+from snapcraft import config
43+from snapcraft.internal.indicators import download_requests_stream
44+from snapcraft.storeapi import (
45+ _upload,
46+ constants,
47+ errors,
48+)
49+
50+
51+logger = logging.getLogger(__name__)
52+
53+JSON_CONTENT_TYPE = {'Content-Type': 'application/json'}
54+JSON_ACCEPT = {'Accept': 'application/json'}
55+JSON_HEADERS = dict(**JSON_CONTENT_TYPE, **JSON_ACCEPT)
56+
57+
58+def _macaroon_auth(conf):
59+ """Format a macaroon and its associated discharge.
60+ :return: A string suitable to use in an Authorization header.
61+ """
62+ root_macaroon_raw = conf.get('macaroon')
63+ if root_macaroon_raw is None:
64+ raise errors.InvalidCredentialsError(
65+ 'Root macaroon not in the config file')
66+ unbound_raw = conf.get('unbound_discharge')
67+ if unbound_raw is None:
68+ raise errors.InvalidCredentialsError(
69+ 'Unbound discharge not in the config file')
70+
71+ root_macaroon = _deserialize_macaroon(root_macaroon_raw)
72+ unbound = _deserialize_macaroon(unbound_raw)
73+ bound = root_macaroon.prepare_for_request(unbound)
74+ discharge_macaroon_raw = bound.serialize()
75+ auth = 'Macaroon root={}, discharge={}'.format(
76+ root_macaroon_raw, discharge_macaroon_raw)
77+ return auth
78+
79+
80+def _deserialize_macaroon(value):
81+ try:
82+ return pymacaroons.Macaroon.deserialize(value)
83+ except:
84+ raise errors.InvalidCredentialsError('Failed to deserialize macaroon')
85+
86+
87+class Client:
88+ """A base class to define clients for the ols servers.
89+ This is a simple wrapper around requests.Session so we inherit all good
90+ bits while providing a simple point for tests to override when needed.
91+ """
92+
93+ def __init__(self, conf, root_url):
94+ self.conf = conf
95+ self.root_url = root_url
96+ self.session = requests.Session()
97+
98+ def request(self, method, url, params=None, headers=None, **kwargs):
99+ """Overriding base class to handle the root url."""
100+ # Note that url may be absolute in which case 'root_url' is ignored by
101+ # urljoin.
102+
103+ if not headers:
104+ headers = JSON_HEADERS
105+
106+ final_url = urllib.parse.urljoin(self.root_url, url)
107+ response = self.session.request(
108+ method, final_url, headers=headers,
109+ params=params, **kwargs)
110+ return response
111+
112+ def get(self, url, **kwargs):
113+ return self.request('GET', url, **kwargs)
114+
115+ def post(self, url, **kwargs):
116+ return self.request('POST', url, **kwargs)
117+
118+ def put(self, url, **kwargs):
119+ return self.request('PUT', url, **kwargs)
120+
121+
122+class StoreClient:
123+ """High-level client for the V2.0 API SCA resources."""
124+
125+ def __init__(self):
126+ super().__init__()
127+ self.conf = config.Config()
128+ self.sso = SSOClient(self.conf)
129+ self.cpi = SnapIndexClient(self.conf)
130+ self.updown = UpDownClient(self.conf)
131+ self.sca = SCAClient(self.conf)
132+
133+ def login(self, email, password, one_time_password=None, acls=None,
134+ packages=None, channels=None, save=True):
135+ """Log in via the Ubuntu One SSO API."""
136+ if acls is None:
137+ acls = ['package_upload', 'package_access']
138+ # Ask the store for the needed capabilities to be associated with the
139+ # macaroon.
140+ macaroon = self.sca.get_macaroon(acls, packages, channels)
141+ caveat_id = self._extract_caveat_id(macaroon)
142+ unbound_discharge = self.sso.get_unbound_discharge(
143+ email, password, one_time_password, caveat_id)
144+ # The macaroon has been discharged, save it in the config
145+ self.conf.set('macaroon', macaroon)
146+ self.conf.set('unbound_discharge', unbound_discharge)
147+ if save:
148+ self.conf.save()
149+
150+ @property
151+ def config(self):
152+ return self.conf
153+
154+ def load_config(self):
155+ self.conf.load()
156+
157+ def _extract_caveat_id(self, root_macaroon):
158+ macaroon = pymacaroons.Macaroon.deserialize(root_macaroon)
159+ # macaroons are all bytes, never strings
160+ sso_host = urllib.parse.urlparse(self.sso.root_url).netloc
161+ for caveat in macaroon.caveats:
162+ if caveat.location == sso_host:
163+ return caveat.caveat_id
164+ else:
165+ raise errors.InvalidCredentialsError('Invalid root macaroon')
166+
167+ def logout(self):
168+ self.conf.clear()
169+ self.conf.save()
170+
171+ def _refresh_if_necessary(self, func, *args, **kwargs):
172+ """Make a request, refreshing macaroons if necessary."""
173+ try:
174+ return func(*args, **kwargs)
175+ except errors.StoreMacaroonNeedsRefreshError:
176+ unbound_discharge = self.sso.refresh_unbound_discharge(
177+ self.conf.get('unbound_discharge'))
178+ self.conf.set('unbound_discharge', unbound_discharge)
179+ self.conf.save()
180+ return func(*args, **kwargs)
181+
182+ def get_account_information(self):
183+ return self._refresh_if_necessary(self.sca.get_account_information)
184+
185+ def register_key(self, account_key_request):
186+ return self._refresh_if_necessary(
187+ self.sca.register_key, account_key_request)
188+
189+ def register(self, snap_name, is_private=False):
190+ return self._refresh_if_necessary(
191+ self.sca.register, snap_name, is_private, constants.DEFAULT_SERIES)
192+
193+ def push_precheck(self, snap_name):
194+ return self._refresh_if_necessary(
195+ self.sca.snap_push_precheck, snap_name)
196+
197+ def push_snap_build(self, snap_id, snap_build):
198+ return self._refresh_if_necessary(
199+ self.sca.push_snap_build, snap_id, snap_build)
200+
201+ def upload(self, snap_name, snap_filename):
202+ if self.conf.get('unbound_discharge') is None:
203+ raise errors.InvalidCredentialsError(
204+ 'Unbound discharge not in the config file')
205+
206+ updown_data = _upload.upload_files(snap_filename, self.updown)
207+
208+ return self._refresh_if_necessary(
209+ self.sca.snap_push_metadata, snap_name, updown_data)
210+
211+ def release(self, snap_name, revision, channels):
212+ return self._refresh_if_necessary(
213+ self.sca.snap_release, snap_name, revision, channels)
214+
215+ def get_snap_history(self, snap_name, series=None, arch=None):
216+ if series is None:
217+ series = constants.DEFAULT_SERIES
218+
219+ account_info = self.get_account_information()
220+ try:
221+ snap_id = account_info['snaps'][series][snap_name]['snap-id']
222+ except KeyError:
223+ raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
224+
225+ response = self._refresh_if_necessary(
226+ self.sca.snap_history, snap_id, series, arch)
227+
228+ if not response:
229+ raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
230+
231+ return response
232+
233+ def get_snap_status(self, snap_name, series=None, arch=None):
234+ if series is None:
235+ series = constants.DEFAULT_SERIES
236+
237+ account_info = self.get_account_information()
238+ try:
239+ snap_id = account_info['snaps'][series][snap_name]['snap-id']
240+ except KeyError:
241+ raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
242+
243+ response = self._refresh_if_necessary(
244+ self.sca.snap_status, snap_id, series, arch)
245+
246+ if not response:
247+ raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
248+
249+ return response
250+
251+ def close_channels(self, snap_id, channel_names):
252+ return self._refresh_if_necessary(
253+ self.sca.close_channels, snap_id, channel_names)
254+
255+ def download(self, snap_name, channel, download_path, arch=None):
256+ if arch is None:
257+ arch = snapcraft.ProjectOptions().deb_arch
258+
259+ package = self.cpi.get_package(snap_name, channel, arch)
260+ self._download_snap(
261+ snap_name, channel, arch, download_path,
262+ package['anon_download_url'], package['download_sha512'])
263+
264+ def _download_snap(self, name, channel, arch, download_path,
265+ download_url, expected_sha512):
266+ if self._is_downloaded(download_path, expected_sha512):
267+ logger.info('Already downloaded {} at {}'.format(
268+ name, download_path))
269+ return
270+ logger.info('Downloading {}'.format(name, download_path))
271+ request = self.cpi.get(download_url, stream=True)
272+ request.raise_for_status()
273+ download_requests_stream(request, download_path)
274+
275+ if self._is_downloaded(download_path, expected_sha512):
276+ logger.info('Successfully downloaded {} at {}'.format(
277+ name, download_path))
278+ else:
279+ raise errors.SHAMismatchError(download_path, expected_sha512)
280+
281+ def _is_downloaded(self, path, expected_sha512):
282+ if not os.path.exists(path):
283+ return False
284+
285+ file_sum = hashlib.sha512()
286+ with open(path, 'rb') as f:
287+ for file_chunk in iter(
288+ lambda: f.read(file_sum.block_size * 128), b''):
289+ file_sum.update(file_chunk)
290+ return expected_sha512 == file_sum.hexdigest()
291+
292+ def push_validation(self, snap_id, assertion):
293+ return self.sca.push_validation(snap_id, assertion)
294+
295+ def get_validations(self, snap_id):
296+ return self.sca.get_validations(snap_id)
297+
298+ def sign_developer_agreement(self, latest_tos_accepted=False):
299+ return self.sca.sign_developer_agreement(latest_tos_accepted)
300+
301+
302+class SSOClient(Client):
303+ """The Single Sign On server deals with authentication.
304+ It is used directly or indirectly by other servers.
305+ """
306+ def __init__(self, conf):
307+ super().__init__(conf, os.environ.get(
308+ 'UBUNTU_SSO_API_ROOT_URL',
309+ constants.UBUNTU_SSO_API_ROOT_URL))
310+
311+ def get_unbound_discharge(self, email, password, one_time_password,
312+ caveat_id):
313+ data = dict(email=email, password=password,
314+ caveat_id=caveat_id)
315+ if one_time_password:
316+ data['otp'] = one_time_password
317+ response = self.post(
318+ 'tokens/discharge', data=json.dumps(data),
319+ headers=JSON_HEADERS)
320+ try:
321+ response_json = response.json()
322+ except JSONDecodeError:
323+ response_json = {}
324+ if response.ok:
325+ return response_json['discharge_macaroon']
326+ else:
327+ if (response.status_code == requests.codes.unauthorized and
328+ any(error.get('code') == 'twofactor-required'
329+ for error in response_json.get('error_list', []))):
330+ raise errors.StoreTwoFactorAuthenticationRequired()
331+ else:
332+ raise errors.StoreAuthenticationError(
333+ 'Failed to get unbound discharge: {}'.format(
334+ response.text))
335+
336+ def refresh_unbound_discharge(self, unbound_discharge):
337+ data = {'discharge_macaroon': unbound_discharge}
338+ response = self.post(
339+ 'tokens/refresh', data=json.dumps(data),
340+ headers=JSON_HEADERS)
341+ if response.ok:
342+ return response.json()['discharge_macaroon']
343+ else:
344+ raise errors.StoreAuthenticationError(
345+ 'Failed to refresh unbound discharge: {}'.format(
346+ response.text))
347+
348+
349+class SnapIndexClient(Client):
350+ """The Click Package Index knows everything about existing snaps.
351+ https://wiki.ubuntu.com/AppStore/Interfaces/ClickPackageIndex is the
352+ canonical reference.
353+ """
354+ def __init__(self, conf):
355+ super().__init__(conf, os.environ.get(
356+ 'UBUNTU_STORE_SEARCH_ROOT_URL',
357+ constants.UBUNTU_STORE_SEARCH_ROOT_URL))
358+
359+ def get_default_headers(self):
360+ """Return default headers for CPI requests.
361+
362+ Tries to build an 'Authorization' header with local credentials
363+ if they are available.
364+ Also pin specific branded store if `SNAPCRAFT_UBUNTU_STORE`
365+ environment is set.
366+ """
367+ headers = {}
368+
369+ with contextlib.suppress(errors.InvalidCredentialsError):
370+ headers['Authorization'] = _macaroon_auth(self.conf)
371+
372+ branded_store = os.getenv('SNAPCRAFT_UBUNTU_STORE')
373+ if branded_store:
374+ headers['X-Ubuntu-Store'] = branded_store
375+
376+ return headers
377+
378+ def get_package(self, snap_name, channel, arch=None):
379+ headers = self.get_default_headers()
380+ headers.update({
381+ 'Accept': 'application/hal+json',
382+ 'X-Ubuntu-Release': constants.DEFAULT_SERIES,
383+ })
384+ if arch:
385+ headers['X-Ubuntu-Architecture'] = arch
386+
387+ params = {
388+ 'channel': channel,
389+ 'fields': 'status,anon_download_url,download_url,'
390+ 'download_sha512,snap_id,release',
391+ }
392+ logger.info('Getting details for {}'.format(snap_name))
393+ url = 'api/v1/snaps/details/{}'.format(snap_name)
394+ resp = self.get(url, headers=headers, params=params)
395+ if resp.status_code != 200:
396+ raise errors.SnapNotFoundError(snap_name, channel, arch)
397+ return resp.json()
398+
399+ def get(self, url, headers=None, params=None, stream=False):
400+ if headers is None:
401+ headers = self.get_default_headers()
402+ response = self.request('GET', url, stream=stream,
403+ headers=headers, params=params)
404+ return response
405+
406+
407+class UpDownClient(Client):
408+ """The Up/Down server provide upload/download snap capabilities."""
409+
410+ def __init__(self, conf):
411+ super().__init__(conf, os.environ.get(
412+ 'UBUNTU_STORE_UPLOAD_ROOT_URL',
413+ constants.UBUNTU_STORE_UPLOAD_ROOT_URL))
414+
415+ def upload(self, monitor):
416+ return self.post(
417+ urllib.parse.urljoin(self.root_url, 'unscanned-upload/'),
418+ data=monitor,
419+ headers=dict(**{'Content-Type': monitor.content_type},
420+ **JSON_ACCEPT))
421+
422+
423+class SCAClient(Client):
424+ """The software center agent deals with managing snaps."""
425+
426+ def __init__(self, conf):
427+ super().__init__(conf, os.environ.get(
428+ 'UBUNTU_STORE_API_ROOT_URL',
429+ constants.UBUNTU_STORE_API_ROOT_URL))
430+
431+ def get_macaroon(self, acls, packages=None, channels=None):
432+ data = {
433+ 'permissions': acls,
434+ }
435+ if packages is not None:
436+ data.update({
437+ 'packages': packages,
438+ })
439+ if channels is not None:
440+ data.update({
441+ 'channels': channels,
442+ })
443+ headers = JSON_ACCEPT
444+ response = self.post(
445+ 'acl/', json=data, headers=headers)
446+ if response.ok:
447+ return response.json()['macaroon']
448+ else:
449+ raise errors.StoreAuthenticationError('Failed to get macaroon')
450+
451+ @staticmethod
452+ def _is_needs_refresh_response(response):
453+ return (
454+ response.status_code == requests.codes.unauthorized and
455+ response.headers.get('WWW-Authenticate') == (
456+ 'Macaroon needs_refresh=1'))
457+
458+ def request(self, *args, **kwargs):
459+ response = super().request(*args, **kwargs)
460+ if self._is_needs_refresh_response(response):
461+ raise errors.StoreMacaroonNeedsRefreshError()
462+ return response
463+
464+ def get_account_information(self):
465+ auth = _macaroon_auth(self.conf)
466+ response = self.get(
467+ 'account',
468+ headers=dict(**{'Authorization': auth}, **JSON_ACCEPT))
469+ if response.ok:
470+ return response.json()
471+ else:
472+ raise errors.StoreAccountInformationError(response)
473+
474+ def register_key(self, account_key_request):
475+ data = {'account_key_request': account_key_request}
476+ auth = _macaroon_auth(self.conf)
477+ response = self.post(
478+ 'account/account-key', data=json.dumps(data),
479+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
480+ if not response.ok:
481+ raise errors.StoreKeyRegistrationError(response)
482+
483+ def register(self, snap_name, is_private, series):
484+ auth = _macaroon_auth(self.conf)
485+ data = dict(snap_name=snap_name, is_private=is_private,
486+ series=series)
487+ response = self.post(
488+ 'register-name/', data=json.dumps(data),
489+ headers=dict(**{'Authorization': auth}, **JSON_ACCEPT))
490+ if not response.ok:
491+ raise errors.StoreRegistrationError(snap_name, response)
492+
493+ def snap_push_precheck(self, snap_name):
494+ data = {
495+ 'name': snap_name,
496+ 'dry_run': True,
497+ }
498+ auth = _macaroon_auth(self.conf)
499+ response = self.post(
500+ 'snap-push/', data=json.dumps(data),
501+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
502+ if not response.ok:
503+ raise errors.StorePushError(data['name'], response)
504+
505+ def snap_push_metadata(self, snap_name, updown_data):
506+ data = {
507+ 'name': snap_name,
508+ 'series': constants.DEFAULT_SERIES,
509+ 'updown_id': updown_data['upload_id'],
510+ 'binary_filesize': updown_data['binary_filesize'],
511+ 'source_uploaded': updown_data['source_uploaded'],
512+ }
513+ auth = _macaroon_auth(self.conf)
514+ response = self.post(
515+ 'snap-push/', data=json.dumps(data),
516+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
517+ if not response.ok:
518+ raise errors.StorePushError(data['name'], response)
519+
520+ return StatusTracker(response.json()['status_details_url'])
521+
522+ def snap_release(self, snap_name, revision, channels):
523+ data = {
524+ 'name': snap_name,
525+ 'revision': str(revision),
526+ 'channels': channels,
527+ }
528+ auth = _macaroon_auth(self.conf)
529+ response = self.post(
530+ 'snap-release/', data=json.dumps(data),
531+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
532+ if not response.ok:
533+ raise errors.StoreReleaseError(data['name'], response)
534+
535+ response_json = response.json()
536+
537+ return response_json
538+
539+ def push_validation(self, snap_id, assertion):
540+ data = {
541+ 'assertion': assertion.decode('utf-8'),
542+ }
543+ auth = _macaroon_auth(self.conf)
544+ response = self.put(
545+ 'snaps/{}/validations'.format(snap_id), data=json.dumps(data),
546+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
547+ if not response.ok:
548+ raise errors.StoreValidationError(snap_id, response)
549+ try:
550+ response_json = response.json()
551+ except JSONDecodeError:
552+ message = ('Invalid response from the server when pushing '
553+ 'validations: {} {}').format(
554+ response.status_code, response)
555+ logger.debug(message)
556+ raise errors.StoreValidationError(
557+ snap_id, response, message='Invalid response from the server')
558+
559+ return response_json
560+
561+ def get_validations(self, snap_id):
562+ auth = _macaroon_auth(self.conf)
563+ response = self.get(
564+ 'snaps/{}/validations'.format(snap_id),
565+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
566+ if not response.ok:
567+ raise errors.StoreValidationError(snap_id, response)
568+ try:
569+ response_json = response.json()
570+ except JSONDecodeError:
571+ message = ('Invalid response from the server when getting '
572+ 'validations: {} {}').format(
573+ response.status_code, response)
574+ logger.debug(message)
575+ raise errors.StoreValidationError(
576+ snap_id, response, message='Invalid response from the server')
577+
578+ return response_json
579+
580+ def push_snap_build(self, snap_id, snap_build):
581+ url = 'snaps/{}/builds'.format(snap_id)
582+ data = json.dumps({"assertion": snap_build})
583+
584+ headers = dict(**{'Authorization': _macaroon_auth(self.conf)},
585+ **JSON_CONTENT_TYPE)
586+ response = self.post(url, data=data, headers=headers)
587+ if not response.ok:
588+ raise errors.StoreSnapBuildError(response)
589+
590+ def snap_history(self, snap_id, series, arch):
591+ qs = {}
592+ if series:
593+ qs['series'] = series
594+ if arch:
595+ qs['arch'] = arch
596+ url = 'snaps/' + snap_id + '/history'
597+ if qs:
598+ url += '?' + urllib.parse.urlencode(qs)
599+ auth = _macaroon_auth(self.conf)
600+ response = self.get(
601+ url,
602+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
603+ if not response.ok:
604+ raise errors.StoreSnapHistoryError(response, snap_id, series, arch)
605+
606+ response_json = response.json()
607+
608+ return response_json
609+
610+ def snap_status(self, snap_id, series, arch):
611+ qs = {}
612+ if series:
613+ qs['series'] = series
614+ if arch:
615+ qs['arch'] = arch
616+ url = 'snaps/' + snap_id + '/status'
617+ if qs:
618+ url += '?' + urllib.parse.urlencode(qs)
619+ auth = _macaroon_auth(self.conf)
620+ response = self.get(
621+ url,
622+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
623+ if not response.ok:
624+ raise errors.StoreSnapStatusError(response, snap_id, series, arch)
625+
626+ response_json = response.json()
627+
628+ return response_json
629+
630+ def close_channels(self, snap_id, channel_names):
631+ url = 'snaps/{}/close'.format(snap_id)
632+ data = {
633+ 'channels': channel_names
634+ }
635+ headers = {
636+ 'Authorization': _macaroon_auth(self.conf),
637+ }
638+ response = self.post(url, json=data, headers=headers)
639+ if not response.ok:
640+ raise errors.StoreChannelClosingError(response)
641+
642+ try:
643+ results = response.json()
644+ return results['closed_channels'], results['channel_maps']
645+ except (JSONDecodeError, KeyError):
646+ logger.debug(
647+ 'Invalid response from the server on channel closing:\n'
648+ '{} {}\n{}'.format(response.status_code, response.reason,
649+ response.content))
650+ raise errors.StoreChannelClosingError(response)
651+
652+ def sign_developer_agreement(self, latest_tos_accepted=False):
653+ auth = _macaroon_auth(self.conf)
654+ data = {'latest_tos_accepted': latest_tos_accepted}
655+ response = self.post(
656+ 'agreement/', data=json.dumps(data),
657+ headers=dict(**{'Authorization': auth}, **JSON_HEADERS))
658+ if not response.ok:
659+ raise errors.DeveloperAgreementSignError(response)
660+ return response.json()
661+
662+
663+class StatusTracker:
664+
665+ __messages = {
666+ 'being_processed': 'Processing...',
667+ 'ready_to_release': 'Ready to release!',
668+ 'need_manual_review': 'Will need manual review...',
669+ 'processing_error': 'Error while processing...',
670+ }
671+
672+ __error_codes = (
673+ 'processing_error',
674+ 'need_manual_review',
675+ )
676+
677+ def __init__(self, status_details_url):
678+ self.__status_details_url = status_details_url
679+
680+ def track(self):
681+ queue = Queue()
682+ thread = Thread(target=self._update_status, args=(queue,))
683+ thread.start()
684+
685+ widgets = ['Processing...', AnimatedMarker()]
686+ progress_indicator = ProgressBar(widgets=widgets, maxval=UnknownLength)
687+ progress_indicator.start()
688+
689+ content = {}
690+ for indicator_count in itertools.count():
691+ if not queue.empty():
692+ content = queue.get()
693+ if isinstance(content, Exception):
694+ raise content
695+ widgets[0] = self._get_message(content)
696+ progress_indicator.update(indicator_count)
697+ if content.get('processed'):
698+ break
699+ sleep(0.1)
700+ progress_indicator.finish()
701+
702+ self.__content = content
703+
704+ return content
705+
706+ def raise_for_code(self):
707+ if any(self.__content['code'] == k for k in self.__error_codes):
708+ raise errors.StoreReviewError(self.__content)
709+
710+ def _get_message(self, content):
711+ return self.__messages.get(content['code'], content['code'])
712+
713+ def _update_status(self, queue):
714+ for content in self._get_status():
715+ queue.put(content)
716+ if content['processed']:
717+ break
718+ sleep(constants.SCAN_STATUS_POLL_DELAY)
719+
720+ def _get_status(self):
721+ connection_errors_allowed = 10
722+ while True:
723+ try:
724+ content = requests.get(self.__status_details_url).json()
725+ except (requests.ConnectionError, requests.HTTPError) as e:
726+ if not connection_errors_allowed:
727+ yield e
728+ content = {'processed': False, 'code': 'being_processed'}
729+ connection_errors_allowed -= 1
730+ yield content
731
732=== added file 'tests/helpers/test_base.py'
733--- tests/helpers/test_base.py 1970-01-01 00:00:00 +0000
734+++ tests/helpers/test_base.py 2017-02-03 18:09:41 +0000
735@@ -0,0 +1,29 @@
736+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
737+#
738+# Copyright (C) 2017 Canonical Ltd
739+#
740+# This program is free software: you can redistribute it and/or modify
741+# it under the terms of the GNU General Public License version 3 as
742+# published by the Free Software Foundation.
743+#
744+# This program is distributed in the hope that it will be useful,
745+# but WITHOUT ANY WARRANTY; without even the implied warranty of
746+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
747+# GNU General Public License for more details.
748+#
749+# You should have received a copy of the GNU General Public License
750+# along with this program. If not, see <http://www.gnu.org/licenses/>.
751+
752+import testscenarios
753+import testtools
754+
755+from tests.utils import fixture_setup
756+
757+
758+class SnappyEcosystemTestCase(testscenarios.WithScenarios, testtools.TestCase):
759+
760+ def setUp(self):
761+ super().setUp()
762+ temp_cwd_fixture = fixture_setup.TempCWD()
763+ self.useFixture(temp_cwd_fixture)
764+ self.path = temp_cwd_fixture.path
765
766=== added file 'tests/utils/fixture_setup.py'
767--- tests/utils/fixture_setup.py 1970-01-01 00:00:00 +0000
768+++ tests/utils/fixture_setup.py 2017-02-03 18:09:41 +0000
769@@ -0,0 +1,81 @@
770+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
771+#
772+# Copyright (C) 2017 Canonical Ltd
773+#
774+# This program is free software: you can redistribute it and/or modify
775+# it under the terms of the GNU General Public License version 3 as
776+# published by the Free Software Foundation.
777+#
778+# This program is distributed in the hope that it will be useful,
779+# but WITHOUT ANY WARRANTY; without even the implied warranty of
780+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
781+# GNU General Public License for more details.
782+#
783+# You should have received a copy of the GNU General Public License
784+# along with this program. If not, see <http://www.gnu.org/licenses/>.
785+
786+import os
787+import fixtures
788+
789+
790+class TempCWD(fixtures.TempDir):
791+
792+ def setUp(self):
793+ """Create a temporary directory an cd into it for the test duration."""
794+ super().setUp()
795+ current_dir = os.getcwd()
796+ self.addCleanup(os.chdir, current_dir)
797+ os.chdir(self.path)
798+
799+
800+class CleanEnvironment(fixtures.Fixture):
801+
802+ def setUp(self):
803+ super().setUp()
804+
805+ current_environment = os.environ.copy()
806+ os.environ = {}
807+
808+ self.addCleanup(os.environ.update, current_environment)
809+
810+
811+class StagingStore(fixtures.Fixture):
812+
813+ def setUp(self):
814+ # TODO: store urls in a config file
815+ super().setUp()
816+ self.useFixture(fixtures.EnvironmentVariable(
817+ 'UBUNTU_STORE_API_ROOT_URL',
818+ 'https://myapps.developer.staging.ubuntu.com/dev/api/'))
819+ self.useFixture(fixtures.EnvironmentVariable(
820+ 'UBUNTU_STORE_UPLOAD_ROOT_URL',
821+ 'https://upload.apps.staging.ubuntu.com/'))
822+ self.useFixture(fixtures.EnvironmentVariable(
823+ 'UBUNTU_SSO_API_ROOT_URL',
824+ 'https://login.staging.ubuntu.com/api/v2/'))
825+ self.useFixture(fixtures.EnvironmentVariable(
826+ 'UBUNTU_STORE_SEARCH_ROOT_URL',
827+ 'https://search.apps.staging.ubuntu.com/'))
828+
829+
830+class TestStore(fixtures.Fixture):
831+
832+ def __init__(self):
833+ self.reserved_snap_name = ''
834+ self.register_delay = -1
835+
836+ def setUp(self):
837+ super().setUp()
838+ # TODO: read store value from config file or execution parameters
839+ test_store = os.getenv('TEST_STORE', 'staging')
840+ if test_store == 'staging':
841+ self.useFixture(StagingStore())
842+ self.register_delay = 10
843+ elif test_store == 'production':
844+ # Use the default server URLs
845+ self.register_delay = 180
846+ else:
847+ raise ValueError(
848+ 'Unknown test store option: {}'.format(test_store))
849+
850+

Subscribers

People subscribed via source and target branches