Merge lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers into lp:snappy-ecosystem-tests
- store-rest-helpers
- Merge into trunk
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 |
Related bugs: |
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
- 6. By Heber Parrucci
-
adding default json headers in base client
I Ahmad (iahmad) wrote : | # |
Omer Akram (om26er) wrote : | # |
Thanks for working on this, I was not able to test the code but wrote some inline comments and suggestions.
Omer Akram (om26er) wrote : | # |
We are missing docstrings, so it would also be helpful if you could add docstrings for the methods.
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:6
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
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
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
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 | + |
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