Merge lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers into lp:snappy-ecosystem-tests
- store-rest-helpers
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Omer Akram |
Approved revision: | 20 |
Merged at revision: | 21 |
Proposed branch: | lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers |
Merge into: | lp:snappy-ecosystem-tests |
Prerequisite: | lp:~canonical-platform-qa/snappy-ecosystem-tests/adding-test-runner |
Diff against target: |
2361 lines (+2112/-101) 19 files modified
README.rst (+19/-0) pylint.cfg (+1/-1) requirements.txt (+3/-0) snappy_ecosystem_tests/helpers/fixture_setup.py (+5/-6) snappy_ecosystem_tests/helpers/snapcraft/__init__.py (+19/-0) snappy_ecosystem_tests/helpers/snapcraft/client.py (+74/-0) snappy_ecosystem_tests/helpers/snapcraft/config.py (+114/-0) snappy_ecosystem_tests/helpers/snapcraft/constants.py (+52/-0) snappy_ecosystem_tests/helpers/snapcraft/deprecations.py (+56/-0) snappy_ecosystem_tests/helpers/snapcraft/indicators.py (+118/-0) snappy_ecosystem_tests/helpers/snapcraft/options.py (+244/-0) snappy_ecosystem_tests/helpers/store_apis/__init__.py (+19/-0) snappy_ecosystem_tests/helpers/store_apis/errors.py (+371/-0) snappy_ecosystem_tests/helpers/store_apis/rest_apis.py (+880/-0) snappy_ecosystem_tests/helpers/store_apis/upload.py (+97/-0) snappy_ecosystem_tests/snapcraft/__init__.py (+0/-19) snappy_ecosystem_tests/snapcraft/snapcraft.py (+0/-74) snappy_ecosystem_tests/tests/test_store_apis_login.py (+39/-0) snappy_ecosystem_tests/tests/test_store_login.py (+1/-1) |
To merge this branch: | bzr merge lp:~canonical-platform-qa/snappy-ecosystem-tests/store-rest-helpers |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
platform-qa-bot | continuous-integration | Approve | |
Omer Akram (community) | Approve | ||
Santiago Baldassin (community) | Approve | ||
Review via email:
|
This proposal supersedes a proposal from 2017-02-03.
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
A simple test was added for making sure we can authenticate against Store RESTful APIs. You can run it: ./run_system_tests snappy_
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
I Ahmad (iahmad) wrote : Posted in a previous version of this proposal | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Omer Akram (om26er) wrote : Posted in a previous version of this proposal | # |
Thanks for working on this, I was not able to test the code but wrote some inline comments and suggestions.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Omer Akram (om26er) wrote : Posted in a previous version of this proposal | # |
We are missing docstrings, so it would also be helpful if you could add docstrings for the methods.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : Posted in a previous version of this proposal | # |
FAILED: Continuous integration, rev:6
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Santiago Baldassin (sbaldassin) wrote : Posted in a previous version of this proposal | # |
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
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Heber Parrucci (heber013) wrote : Posted in a previous version of this proposal | # |
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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:/
- 7. By Heber Parrucci
-
Fixing comments on code review.
Fixing pylint issues.
Addind a simple test for login to Store REST API
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:7
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 8. By Heber Parrucci
-
fixing error in requirements.txt
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:8
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 9. By Heber Parrucci
-
removing duplicated dependencies in requirements.txt
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:9
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 10. By Heber Parrucci
-
changing dict initialization sintax according to PEP 448
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:10
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 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
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:11
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 13. By Heber Parrucci
-
changing dict initialization
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:12
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
FAILED: Continuous integration, rev:13
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 14. By Heber Parrucci
-
chaning dict initialization until jenkins slave is updated
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:14
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Omer Akram (om26er) wrote : | # |
Can you tell how should the config file look like ? I ran the tests and got http://
- 15. By Heber Parrucci
-
Updating README.rst with user credentials instructions
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Heber Parrucci (heber013) wrote : | # |
> Can you tell how should the config file look like ? I ran the tests and got
> http://
Thanks for reviewing!
I have added a section in README that is called: 'User Credentials' here it explains the alternatives for providing them.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:15
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 16. By Heber Parrucci
-
merge from trunk
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:16
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 17. By Heber Parrucci
-
Updating README.rst to make storing credentials section more clear.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:17
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
- 18. By Heber Parrucci
-
renaming class Store and adding a better docstring
- 19. By Heber Parrucci
-
fixing pylint
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:19
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Santiago Baldassin (sbaldassin) wrote : | # |
Code looks good to me
- 20. By Heber Parrucci
-
updating license year in headers
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Omer Akram (om26er) wrote : | # |
code looks good. test passes. Lets land it.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:20
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
- 21. By Heber Parrucci
-
merge from trunk
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) : | # |
Preview Diff
1 | === modified file 'README.rst' |
2 | --- README.rst 2017-02-13 16:00:04 +0000 |
3 | +++ README.rst 2017-02-20 17:48:06 +0000 |
4 | @@ -56,6 +56,25 @@ |
5 | |
6 | pytest.cfg stores the config related to pytest |
7 | |
8 | +User Credentials |
9 | +================ |
10 | +For storing user credentials you have 2 options: |
11 | + |
12 | +option 1 - You can create a config file in your local machine with path: |
13 | +~/.config/ecosystem_tests.cfg |
14 | +You can change the dir location of that config file by setting the env variable: |
15 | +XDG_USER_CONFIG_HOME=^DIR_PATH^ |
16 | + |
17 | +The config file should look like: |
18 | +[user] |
19 | +user_email=^USER_NAME^ |
20 | +user_password=^USER_PASSWORD^ |
21 | + |
22 | +option 2 - Set the following environment variables: |
23 | +user_email=^USER_NAME^ |
24 | +user_password=^USER_PASSWORD^ |
25 | + |
26 | + |
27 | Changing store: |
28 | =============== |
29 | You can change the store to use in config file ecosystem_tests.cfg |
30 | |
31 | === modified file 'pylint.cfg' |
32 | --- pylint.cfg 2017-02-15 19:14:19 +0000 |
33 | +++ pylint.cfg 2017-02-20 17:48:06 +0000 |
34 | @@ -336,7 +336,7 @@ |
35 | [DESIGN] |
36 | |
37 | # Maximum number of arguments for function / method |
38 | -max-args=5 |
39 | +max-args=8 |
40 | |
41 | # Argument names that match this expression will be ignored. Default to name |
42 | # with leading underscore |
43 | |
44 | === modified file 'requirements.txt' |
45 | --- requirements.txt 2017-02-13 17:43:02 +0000 |
46 | +++ requirements.txt 2017-02-20 17:48:06 +0000 |
47 | @@ -1,3 +1,4 @@ |
48 | +libnacl |
49 | pytest |
50 | testtools |
51 | unittest2 |
52 | @@ -11,4 +12,6 @@ |
53 | pyxdg==0.25 |
54 | jsonschema==2.5.1 |
55 | pexpect |
56 | +pymacaroons==0.9.2 |
57 | +requests-toolbelt==0.6.0 |
58 | chromedriver_installer |
59 | |
60 | === modified file 'snappy_ecosystem_tests/helpers/fixture_setup.py' |
61 | --- snappy_ecosystem_tests/helpers/fixture_setup.py 2017-02-15 19:14:19 +0000 |
62 | +++ snappy_ecosystem_tests/helpers/fixture_setup.py 2017-02-20 17:48:06 +0000 |
63 | @@ -18,10 +18,13 @@ |
64 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
65 | # |
66 | |
67 | -"""Manage general tests fixtures""" |
68 | + |
69 | +"""General fixtures related to the store and the tests environment""" |
70 | |
71 | import os |
72 | + |
73 | import fixtures |
74 | + |
75 | from snappy_ecosystem_tests.commons.config import CONFIG_STACK |
76 | from snappy_ecosystem_tests.utils.storeconfig import get_current_store |
77 | |
78 | @@ -30,10 +33,8 @@ |
79 | """Create a temporary directory an cd into it for the test duration.""" |
80 | |
81 | def setUp(self): |
82 | - """Create a temporary directory an cd into it for the test duration.""" |
83 | super().setUp() |
84 | - current_dir = os.getcwd() |
85 | - self.addCleanup(os.chdir, current_dir) |
86 | + self.addCleanup(os.chdir, os.getcwd()) |
87 | os.chdir(self.path) |
88 | |
89 | |
90 | @@ -43,10 +44,8 @@ |
91 | |
92 | def setUp(self): |
93 | super().setUp() |
94 | - |
95 | current_environment = os.environ.copy() |
96 | os.environ = {} |
97 | - |
98 | self.addCleanup(os.environ.update, current_environment) |
99 | |
100 | |
101 | |
102 | === added directory 'snappy_ecosystem_tests/helpers/snapcraft' |
103 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/__init__.py' |
104 | --- snappy_ecosystem_tests/helpers/snapcraft/__init__.py 1970-01-01 00:00:00 +0000 |
105 | +++ snappy_ecosystem_tests/helpers/snapcraft/__init__.py 2017-02-20 17:48:06 +0000 |
106 | @@ -0,0 +1,19 @@ |
107 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
108 | + |
109 | +# |
110 | +# Snappy Ecosystem Tests |
111 | +# Copyright (C) 2017 Canonical |
112 | +# |
113 | +# This program is free software: you can redistribute it and/or modify |
114 | +# it under the terms of the GNU General Public License as published by |
115 | +# the Free Software Foundation, either version 3 of the License, or |
116 | +# (at your option) any later version. |
117 | +# |
118 | +# This program is distributed in the hope that it will be useful, |
119 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
120 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
121 | +# GNU General Public License for more details. |
122 | +# |
123 | +# You should have received a copy of the GNU General Public License |
124 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
125 | +# |
126 | |
127 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/client.py' |
128 | --- snappy_ecosystem_tests/helpers/snapcraft/client.py 1970-01-01 00:00:00 +0000 |
129 | +++ snappy_ecosystem_tests/helpers/snapcraft/client.py 2017-02-20 17:48:06 +0000 |
130 | @@ -0,0 +1,74 @@ |
131 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
132 | + |
133 | +# |
134 | +# Snappy Ecosystem Tests |
135 | +# Copyright (C) 2017 Canonical |
136 | +# |
137 | +# This program is free software: you can redistribute it and/or modify |
138 | +# it under the terms of the GNU General Public License as published by |
139 | +# the Free Software Foundation, either version 3 of the License, or |
140 | +# (at your option) any later version. |
141 | +# |
142 | +# This program is distributed in the hope that it will be useful, |
143 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
144 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
145 | +# GNU General Public License for more details. |
146 | +# |
147 | +# You should have received a copy of the GNU General Public License |
148 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
149 | +# |
150 | + |
151 | +"""Snapcraft client helpers""" |
152 | + |
153 | +import pexpect |
154 | + |
155 | + |
156 | +# login credentials exported by shell environment |
157 | +from snappy_ecosystem_tests.utils import storeconfig |
158 | + |
159 | +LOGIN_EMAIL, LOGIN_PASSWORD = storeconfig.get_store_credentials() |
160 | + |
161 | + |
162 | +class Snapcraft(object): |
163 | + """Contain Snapcraft specific functionality to use via command |
164 | + line interface""" |
165 | + def __init__(self): |
166 | + """Create new snapcraft instance.""" |
167 | + self._login = False |
168 | + self._cleanup() |
169 | + |
170 | + def _cleanup(self): |
171 | + """Perform cleanup actions""" |
172 | + self.logout() |
173 | + |
174 | + def logout(self): |
175 | + """logout of snapcraft store session""" |
176 | + child = pexpect.spawn("snapcraft logout") |
177 | + err = child.expect('Credentials cleared.') |
178 | + child.terminate(True) |
179 | + child.wait() |
180 | + if err is not 0: |
181 | + raise ValueError("Failed to logout") |
182 | + |
183 | + self._login = False |
184 | + |
185 | + def login(self): |
186 | + """login to store using the credential environment variables""" |
187 | + child = pexpect.spawn("snapcraft login") |
188 | + child.expect('Email: ') |
189 | + child.sendline(LOGIN_EMAIL) |
190 | + child.expect('Password:') |
191 | + child.sendline(LOGIN_PASSWORD) |
192 | + err = child.expect('Login successful') |
193 | + if err is not 0: |
194 | + raise ValueError("Failed to login") |
195 | + |
196 | + self._login = True |
197 | + |
198 | + def list_registered(self): |
199 | + """call snapcraft list-registered command, |
200 | + raise exception if not logged in""" |
201 | + if self._login is False: |
202 | + raise ValueError("User is not logged in, " |
203 | + "please login before using this command") |
204 | + return pexpect.spawnu("snapcraft list-registered").read() |
205 | |
206 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/config.py' |
207 | --- snappy_ecosystem_tests/helpers/snapcraft/config.py 1970-01-01 00:00:00 +0000 |
208 | +++ snappy_ecosystem_tests/helpers/snapcraft/config.py 2017-02-20 17:48:06 +0000 |
209 | @@ -0,0 +1,114 @@ |
210 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
211 | + |
212 | +# |
213 | +# Snappy Ecosystem Tests |
214 | +# Copyright (C) 2017 Canonical |
215 | +# |
216 | +# This program is free software: you can redistribute it and/or modify |
217 | +# it under the terms of the GNU General Public License as published by |
218 | +# the Free Software Foundation, either version 3 of the License, or |
219 | +# (at your option) any later version. |
220 | +# |
221 | +# This program is distributed in the hope that it will be useful, |
222 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
223 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
224 | +# GNU General Public License for more details. |
225 | +# |
226 | +# You should have received a copy of the GNU General Public License |
227 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
228 | +# |
229 | + |
230 | +"""Helpers to store/manage/load snapcraft configuration in a config file""" |
231 | + |
232 | +import configparser |
233 | +import logging |
234 | +import os |
235 | +import urllib.parse |
236 | + |
237 | +from snappy_ecosystem_tests.commons.config import CONFIG_STACK |
238 | +from xdg import BaseDirectory |
239 | + |
240 | +LOCAL_CONFIG_FILENAME = '.snapcraft/snapcraft.cfg' |
241 | + |
242 | +LOGGER = logging.getLogger(__name__) |
243 | + |
244 | + |
245 | +class SnapcraftConfig(object): |
246 | + """Hold configuration options in sections. |
247 | + There can be two sections for the sso related credentials: production and |
248 | + staging. This is governed by the UBUNTU_SSO_API_ROOT_URL environment |
249 | + variable. Other sections are ignored but preserved. |
250 | + """ |
251 | + |
252 | + def __init__(self): |
253 | + self.parser = configparser.ConfigParser() |
254 | + self.filename = None |
255 | + self.load() |
256 | + |
257 | + @staticmethod |
258 | + def _section_name(): |
259 | + """Return a section""" |
260 | + # The only section we care about is the host from the SSO url |
261 | + url = os.environ.get('UBUNTU_SSO_API_ROOT_URL', |
262 | + CONFIG_STACK.config.get('production_urls', 'sso')) |
263 | + return urllib.parse.urlparse(url).netloc |
264 | + |
265 | + def get(self, option_name): |
266 | + """Get an option name for a given section""" |
267 | + try: |
268 | + return self.parser.get(self._section_name(), option_name) |
269 | + except (configparser.NoSectionError, |
270 | + configparser.NoOptionError, |
271 | + KeyError): |
272 | + return None |
273 | + |
274 | + def set(self, option_name, value): |
275 | + """Set an option with the given value""" |
276 | + section_name = self._section_name() |
277 | + if not self.parser.has_section(section_name): |
278 | + self.parser.add_section(section_name) |
279 | + return self.parser.set(section_name, option_name, value) |
280 | + |
281 | + def is_empty(self): |
282 | + """Return True if snapcraft config does not have the default section |
283 | + name""" |
284 | + # Only check the current section |
285 | + section_name = self._section_name() |
286 | + if self.parser.has_section(section_name): |
287 | + if self.parser.options(section_name): |
288 | + return False |
289 | + return True |
290 | + |
291 | + def load(self): |
292 | + """Load the snapcraft configuration""" |
293 | + # Local configurations (per project) are supposed to be static. |
294 | + # That's why it's only checked for 'loading' and never written to. |
295 | + # Essentially, all authentication-related changes, like login/logout |
296 | + # or macaroon-refresh, will not be persisted for the next runs. |
297 | + if os.path.exists(LOCAL_CONFIG_FILENAME): |
298 | + self.parser.read(LOCAL_CONFIG_FILENAME) |
299 | + LOGGER.warning( |
300 | + 'Using local configuration (`%s`), changes will ' |
301 | + 'not be persisted.', LOCAL_CONFIG_FILENAME) |
302 | + return |
303 | + |
304 | + self.filename = BaseDirectory.load_first_config( |
305 | + 'snapcraft', 'snapcraft.cfg') |
306 | + if self.filename and os.path.exists(self.filename): |
307 | + self.parser.read(self.filename) |
308 | + |
309 | + @staticmethod |
310 | + def save_path(): |
311 | + """Return the save path for the snapcraft configuration""" |
312 | + return os.path.join(BaseDirectory.save_config_path('snapcraft'), |
313 | + 'snapcraft.cfg') |
314 | + |
315 | + def save(self): |
316 | + """Save the current configuration in the save path""" |
317 | + self.filename = self.save_path() |
318 | + with open(self.filename, 'w') as _f: |
319 | + self.parser.write(_f) |
320 | + |
321 | + def clear(self): |
322 | + """Clear the current configuration""" |
323 | + self.parser.remove_section(self._section_name()) |
324 | |
325 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/constants.py' |
326 | --- snappy_ecosystem_tests/helpers/snapcraft/constants.py 1970-01-01 00:00:00 +0000 |
327 | +++ snappy_ecosystem_tests/helpers/snapcraft/constants.py 2017-02-20 17:48:06 +0000 |
328 | @@ -0,0 +1,52 @@ |
329 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
330 | + |
331 | +# |
332 | +# Snappy Ecosystem Tests |
333 | +# Copyright (C) 2017 Canonical |
334 | +# |
335 | +# This program is free software: you can redistribute it and/or modify |
336 | +# it under the terms of the GNU General Public License as published by |
337 | +# the Free Software Foundation, either version 3 of the License, or |
338 | +# (at your option) any later version. |
339 | +# |
340 | +# This program is distributed in the hope that it will be useful, |
341 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
342 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
343 | +# GNU General Public License for more details. |
344 | +# |
345 | +# You should have received a copy of the GNU General Public License |
346 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
347 | +# |
348 | + |
349 | +"""Store snapcraft constants""" |
350 | + |
351 | +from __future__ import absolute_import, unicode_literals |
352 | + |
353 | + |
354 | +DEFAULT_SERIES = '16' |
355 | +SCAN_STATUS_POLL_DELAY = 5 |
356 | +SCAN_STATUS_POLL_RETRIES = 5 |
357 | + |
358 | +# Messages and warnings. |
359 | +MISSING_AGREEMENT = 'Developer has not signed agreement.' |
360 | +MISSING_NAMESPACE = 'Developer profile is missing short namespace.' |
361 | +AGREEMENT_ERROR = ( |
362 | + 'You must agree to the developer terms and conditions to upload snaps.') |
363 | +NAMESPACE_ERROR = ( |
364 | + 'You need to set a username. It will appear in the developer field ' |
365 | + 'alongside the other details for your snap. Please visit {} and login ' |
366 | + 'again.') |
367 | +AGREEMENT_INPUT_MSG = ( |
368 | + 'Do you agree to the developer terms and conditions. ({})? [y/N] ') |
369 | +AGREEMENT_SIGN_ERROR = ( |
370 | + 'Unexpected error encountered during signing the developer terms and ' |
371 | + 'conditions. Please visit {} and agree to the terms and conditions before ' |
372 | + 'continuing.') |
373 | +TWO_FACTOR_WARNING = ( |
374 | + 'We strongly recommend enabling multi-factor authentication: ' |
375 | + 'https://help.ubuntu.com/community/SSO/FAQs/2FA') |
376 | +INVALID_CREDENTIALS = 'Invalid credentials supplied.' |
377 | +AUTHENTICATION_ERROR = ('Problems encountered when authenticating your ' |
378 | + 'credentials.') |
379 | +ACCOUNT_INFORMATION_ERROR = ('Unexpected error when obtaining your account ' |
380 | + 'information.') |
381 | |
382 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/deprecations.py' |
383 | --- snappy_ecosystem_tests/helpers/snapcraft/deprecations.py 1970-01-01 00:00:00 +0000 |
384 | +++ snappy_ecosystem_tests/helpers/snapcraft/deprecations.py 2017-02-20 17:48:06 +0000 |
385 | @@ -0,0 +1,56 @@ |
386 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
387 | + |
388 | +# |
389 | +# Snappy Ecosystem Tests |
390 | +# Copyright (C) 2017 Canonical |
391 | +# |
392 | +# This program is free software: you can redistribute it and/or modify |
393 | +# it under the terms of the GNU General Public License as published by |
394 | +# the Free Software Foundation, either version 3 of the License, or |
395 | +# (at your option) any later version. |
396 | +# |
397 | +# This program is distributed in the hope that it will be useful, |
398 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
399 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
400 | +# GNU General Public License for more details. |
401 | +# |
402 | +# You should have received a copy of the GNU General Public License |
403 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
404 | +# |
405 | + |
406 | +"""Handle surfacing deprecation notices. |
407 | + |
408 | +When a new deprecation has occurred, write a Deprecation Notice for it on the |
409 | +wiki, assigning it the next ID (DN1, DN2, etc.). Then add that ID along with a |
410 | +brief message for surfacing to the user into the _DEPRECATION_MESSAGES in this |
411 | +module. |
412 | +""" |
413 | + |
414 | +import logging |
415 | + |
416 | +_DEPRECATION_MESSAGES = { |
417 | + 'dn1': "The 'snap' keyword has been replaced by 'prime'.", |
418 | + 'dn2': "Custom plugins should now be placed in 'snap/plugins'.", |
419 | + 'dn3': "Assets in 'setup/gui' should now be placed in 'snap/gui'.", |
420 | +} |
421 | + |
422 | +_DEPRECATION_URL_FMT = 'http://snapcraft.io/docs/deprecation-notices/{id}' |
423 | + |
424 | +LOGGER = logging.getLogger(__name__) |
425 | + |
426 | + |
427 | +def _deprecation_message(_id): |
428 | + """Return the deprecation message for the given id |
429 | + :raise RuntimeError |
430 | + """ |
431 | + message = _DEPRECATION_MESSAGES.get(_id) |
432 | + if not message: |
433 | + raise RuntimeError('No deprecation notice with id {!r}'.format(id)) |
434 | + return message |
435 | + |
436 | + |
437 | +def handle_deprecation_notice(_id): |
438 | + """Handle deprecation notice by logging a message""" |
439 | + message = _deprecation_message(_id) |
440 | + LOGGER.warning('DEPRECATED: %s\nSee %s for more information.', |
441 | + message, _DEPRECATION_URL_FMT.format(id=_id)) |
442 | |
443 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/indicators.py' |
444 | --- snappy_ecosystem_tests/helpers/snapcraft/indicators.py 1970-01-01 00:00:00 +0000 |
445 | +++ snappy_ecosystem_tests/helpers/snapcraft/indicators.py 2017-02-20 17:48:06 +0000 |
446 | @@ -0,0 +1,118 @@ |
447 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
448 | + |
449 | +# |
450 | +# Snappy Ecosystem Tests |
451 | +# Copyright (C) 2017 Canonical |
452 | +# |
453 | +# This program is free software: you can redistribute it and/or modify |
454 | +# it under the terms of the GNU General Public License as published by |
455 | +# the Free Software Foundation, either version 3 of the License, or |
456 | +# (at your option) any later version. |
457 | +# |
458 | +# This program is distributed in the hope that it will be useful, |
459 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
460 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
461 | +# GNU General Public License for more details. |
462 | +# |
463 | +# You should have received a copy of the GNU General Public License |
464 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
465 | +# |
466 | + |
467 | +"""Helpers for snapcraft indicators with pretty progress bar""" |
468 | + |
469 | +import os |
470 | +import sys |
471 | + |
472 | +from urllib.request import urlretrieve |
473 | +from progressbar import ( |
474 | + AnimatedMarker, |
475 | + Bar, |
476 | + Percentage, |
477 | + ProgressBar, |
478 | + UnknownLength, |
479 | +) |
480 | + |
481 | + |
482 | +def _init_progress_bar(total_length, destination, message=None): |
483 | + """Init the progress bar with the given length, destination and message""" |
484 | + if not message: |
485 | + message = 'Downloading {!r}'.format(os.path.basename(destination)) |
486 | + |
487 | + valid_length = total_length and total_length > 0 |
488 | + |
489 | + if valid_length and is_dumb_terminal(): |
490 | + widgets = [message, ' ', Percentage()] |
491 | + maxval = total_length |
492 | + elif valid_length and not is_dumb_terminal(): |
493 | + widgets = [message, |
494 | + Bar(marker='=', left='[', right=']'), |
495 | + ' ', Percentage()] |
496 | + maxval = total_length |
497 | + elif not valid_length and is_dumb_terminal(): |
498 | + widgets = [message] |
499 | + maxval = UnknownLength |
500 | + else: |
501 | + widgets = [message, AnimatedMarker()] |
502 | + maxval = UnknownLength |
503 | + |
504 | + return ProgressBar(widgets=widgets, maxval=maxval) |
505 | + |
506 | + |
507 | +def download_requests_stream(request_stream, destination, message=None): |
508 | + """This is a facility to download a request with nice progress bars.""" |
509 | + |
510 | + # Doing len(request_stream.content) may defeat the purpose of a |
511 | + # progress bar |
512 | + total_length = 0 |
513 | + if not request_stream.headers.get('Content-Encoding', ''): |
514 | + total_length = int(request_stream.headers.get('Content-Length', '0')) |
515 | + |
516 | + total_read = 0 |
517 | + progress_bar = _init_progress_bar(total_length, destination, message) |
518 | + progress_bar.start() |
519 | + with open(destination, 'wb') as destination_file: |
520 | + for buf in request_stream.iter_content(1024): |
521 | + destination_file.write(buf) |
522 | + total_read += len(buf) |
523 | + progress_bar.update(total_read) |
524 | + progress_bar.finish() |
525 | + |
526 | + |
527 | +class UrllibDownloader(object): |
528 | + """This is a facility to download an uri with nice progress bars.""" |
529 | + |
530 | + def __init__(self, uri, destination, message=None): |
531 | + self.uri = uri |
532 | + self.destination = destination |
533 | + self.message = message |
534 | + self.progress_bar = None |
535 | + |
536 | + def download(self): |
537 | + """Download with a pretty progress bar""" |
538 | + urlretrieve(self.uri, self.destination, self._progress_callback) |
539 | + |
540 | + if self.progress_bar: |
541 | + self.progress_bar.finish() |
542 | + |
543 | + def _progress_callback(self, block_num, block_size, total_length): |
544 | + """Callback to be used when downloading""" |
545 | + if not self.progress_bar: |
546 | + self.progress_bar = _init_progress_bar( |
547 | + total_length, self.destination, self.message) |
548 | + self.progress_bar.start() |
549 | + |
550 | + total_read = block_num * block_size |
551 | + self.progress_bar.update( |
552 | + min(total_read, total_length) if total_length > 0 else total_read) |
553 | + |
554 | + |
555 | +def download_urllib_source(uri, destination, message=None): |
556 | + """Perform a download from the given uri to the given destination""" |
557 | + UrllibDownloader(uri, destination, message).download() |
558 | + |
559 | + |
560 | +def is_dumb_terminal(): |
561 | + """Return True if on a dumb terminal.""" |
562 | + is_stdout_tty = os.isatty(sys.stdout.fileno()) |
563 | + is_term_dumb = os.environ.get('TERM', '') == 'dumb' |
564 | + return not is_stdout_tty or is_term_dumb |
565 | |
566 | === added file 'snappy_ecosystem_tests/helpers/snapcraft/options.py' |
567 | --- snappy_ecosystem_tests/helpers/snapcraft/options.py 1970-01-01 00:00:00 +0000 |
568 | +++ snappy_ecosystem_tests/helpers/snapcraft/options.py 2017-02-20 17:48:06 +0000 |
569 | @@ -0,0 +1,244 @@ |
570 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
571 | + |
572 | +# |
573 | +# Snappy Ecosystem Tests |
574 | +# Copyright (C) 2017 Canonical |
575 | +# |
576 | +# This program is free software: you can redistribute it and/or modify |
577 | +# it under the terms of the GNU General Public License as published by |
578 | +# the Free Software Foundation, either version 3 of the License, or |
579 | +# (at your option) any later version. |
580 | +# |
581 | +# This program is distributed in the hope that it will be useful, |
582 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
583 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
584 | +# GNU General Public License for more details. |
585 | +# |
586 | +# You should have received a copy of the GNU General Public License |
587 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
588 | +# |
589 | + |
590 | +"""Manage snapcraft project options""" |
591 | + |
592 | +import logging |
593 | +import multiprocessing |
594 | +import os |
595 | +import platform |
596 | + |
597 | +from snappy_ecosystem_tests.helpers.snapcraft.deprecations import ( |
598 | + handle_deprecation_notice |
599 | +) |
600 | + |
601 | +LOGGER = logging.getLogger(__name__) |
602 | + |
603 | + |
604 | +_ARCH_TRANSLATIONS = { |
605 | + 'armv7l': { |
606 | + 'kernel': 'arm', |
607 | + 'deb': 'armhf', |
608 | + 'cross-compiler-prefix': 'arm-linux-gnueabihf-', |
609 | + 'cross-build-packages': ['gcc-arm-linux-gnueabihf'], |
610 | + 'triplet': 'arm-linux-gnueabihf', |
611 | + 'core-dynamic-linker': 'lib/ld-linux-armhf.so.3', |
612 | + }, |
613 | + 'aarch64': { |
614 | + 'kernel': 'arm64', |
615 | + 'deb': 'arm64', |
616 | + 'cross-compiler-prefix': 'aarch64-linux-gnu-', |
617 | + 'cross-build-packages': ['gcc-aarch64-linux-gnu'], |
618 | + 'triplet': 'aarch64-linux-gnu', |
619 | + 'core-dynamic-linker': 'lib/ld-linux-aarch64.so.1', |
620 | + }, |
621 | + 'i686': { |
622 | + 'kernel': 'x86', |
623 | + 'deb': 'i386', |
624 | + 'triplet': 'i386-linux-gnu', |
625 | + }, |
626 | + 'ppc64le': { |
627 | + 'kernel': 'powerpc', |
628 | + 'deb': 'ppc64el', |
629 | + 'cross-compiler-prefix': 'powerpc64le-linux-gnu-', |
630 | + 'cross-build-packages': ['gcc-powerpc64le-linux-gnu'], |
631 | + 'triplet': 'powerpc64le-linux-gnu', |
632 | + 'core-dynamic-linker': '/lib64/ld64.so.2', |
633 | + }, |
634 | + 'ppc': { |
635 | + 'kernel': 'powerpc', |
636 | + 'deb': 'powerpc', |
637 | + 'cross-compiler-prefix': 'powerpc-linux-gnu-', |
638 | + 'cross-build-packages': ['gcc-powerpc-linux-gnu'], |
639 | + 'triplet': 'powerpc-linux-gnu', |
640 | + }, |
641 | + 'x86_64': { |
642 | + 'kernel': 'x86', |
643 | + 'deb': 'amd64', |
644 | + 'triplet': 'x86_64-linux-gnu', |
645 | + 'core-dynamic-linker': 'lib64/ld-linux-x86-64.so.2', |
646 | + }, |
647 | + 's390x': { |
648 | + 'kernel': 's390x', |
649 | + 'deb': 's390x', |
650 | + 'cross-compiler-prefix': 's390x-linux-gnu-', |
651 | + 'cross-build-packages': ['gcc-s390x-linux-gnu'], |
652 | + 'triplet': 's390x-linux-gnu', |
653 | + 'core-dynamic-linker': '/lib/ld64.so.1', |
654 | + } |
655 | +} |
656 | + |
657 | + |
658 | +_32BIT_USERSPACE_ARCHITECTURE = { |
659 | + 'aarch64': 'armv7l', |
660 | + 'armv8l': 'armv7l', |
661 | + 'ppc64le': 'ppc', |
662 | + 'x86_64': 'i686', |
663 | +} |
664 | + |
665 | + |
666 | +def _get_platform_architecture(): |
667 | + """Return the current platform architecture of the machine""" |
668 | + architecture = platform.machine() |
669 | + if platform.architecture()[0] == '32bit': |
670 | + userspace = _32BIT_USERSPACE_ARCHITECTURE.get(architecture) |
671 | + if userspace: |
672 | + architecture = userspace |
673 | + return architecture |
674 | + |
675 | + |
676 | +class ProjectOptions: |
677 | + """Represents a snapcraft project options""" |
678 | + def __init__(self, use_geoip=False, parallel_builds=True, |
679 | + target_deb_arch=None, debug=False): |
680 | + self.__project_dir = os.getcwd() |
681 | + self.__use_geoip = use_geoip |
682 | + self.__parallel_builds = parallel_builds |
683 | + self._set_machine(target_deb_arch) |
684 | + self.__debug = debug |
685 | + |
686 | + @property |
687 | + def use_geoip(self): |
688 | + """Return True if the project uses geoip""" |
689 | + return self.__use_geoip |
690 | + |
691 | + @property |
692 | + def parallel_builds(self): |
693 | + """Return True if the project uses parallel build""" |
694 | + return self.__parallel_builds |
695 | + |
696 | + @property |
697 | + def parallel_build_count(self): |
698 | + """Return the amount of cpus used for parallel builds""" |
699 | + build_count = 1 |
700 | + if self.__parallel_builds: |
701 | + try: |
702 | + build_count = multiprocessing.cpu_count() |
703 | + except NotImplementedError: |
704 | + LOGGER.warning( |
705 | + 'Unable to determine CPU count; disabling parallel builds') |
706 | + return build_count |
707 | + |
708 | + @property |
709 | + def is_cross_compiling(self): |
710 | + """Return True if the target machine does not match |
711 | + the platform arch of the project""" |
712 | + return self.__target_machine != self.__platform_arch |
713 | + |
714 | + @property |
715 | + def cross_compiler_prefix(self): |
716 | + """Return the cross compiler prefix""" |
717 | + try: |
718 | + return self.__machine_info['cross-compiler-prefix'] |
719 | + except KeyError: |
720 | + raise EnvironmentError( |
721 | + 'Cross compilation not support for target arch {}'.format( |
722 | + self.__target_machine)) |
723 | + |
724 | + @property |
725 | + def additional_build_packages(self): |
726 | + """Return the additional build packages from the machine info""" |
727 | + packages = [] |
728 | + if self.is_cross_compiling: |
729 | + packages.extend(self.__machine_info.get( |
730 | + 'cross-build-packages', [])) |
731 | + return packages |
732 | + |
733 | + @property |
734 | + def arch_triplet(self): |
735 | + """Return the arch triplet from the machine info""" |
736 | + return self.__machine_info['triplet'] |
737 | + |
738 | + @property |
739 | + def deb_arch(self): |
740 | + """Return the deb arch from the machine info""" |
741 | + return self.__machine_info['deb'] |
742 | + |
743 | + @property |
744 | + def kernel_arch(self): |
745 | + """Return the kernel arch from the machine info""" |
746 | + return self.__machine_info['kernel'] |
747 | + |
748 | + @property |
749 | + def local_plugins_dir(self): |
750 | + """Return the plugin directory""" |
751 | + deprecated_plugins_dir = os.path.join(self.parts_dir, 'plugins') |
752 | + if os.path.exists(deprecated_plugins_dir): |
753 | + handle_deprecation_notice('dn2') |
754 | + return deprecated_plugins_dir |
755 | + return os.path.join(self.__project_dir, 'snap', 'plugins') |
756 | + |
757 | + @property |
758 | + def parts_dir(self): |
759 | + """Return the parts directory""" |
760 | + return os.path.join(self.__project_dir, 'parts') |
761 | + |
762 | + @property |
763 | + def stage_dir(self): |
764 | + """Return the stage directory""" |
765 | + return os.path.join(self.__project_dir, 'stage') |
766 | + |
767 | + @property |
768 | + def snap_dir(self): |
769 | + """Return the snap directory""" |
770 | + return os.path.join(self.__project_dir, 'prime') |
771 | + |
772 | + @property |
773 | + def debug(self): |
774 | + """Return debug of the project options""" |
775 | + return self.__debug |
776 | + |
777 | + def get_core_dynamic_linker(self): |
778 | + """Returns the dynamic linker used for the targetted core. |
779 | + If not found realpath for `/lib/ld-linux.so.2` is returned. |
780 | + However if core is not installed None will be returned. |
781 | + """ |
782 | + core_path = os.path.join('/snap', 'core', 'current') |
783 | + core_dynamic_linker = self.__machine_info.get('core-dynamic-linker', |
784 | + 'lib/ld-linux.so.2') |
785 | + |
786 | + try: |
787 | + dynamic_linker_resolved_path = os.readlink( |
788 | + os.path.join(core_path, core_dynamic_linker)) |
789 | + dynamic_linker_path = os.path.join( |
790 | + core_path, dynamic_linker_resolved_path.lstrip('/')) |
791 | + except FileNotFoundError: |
792 | + dynamic_linker_path = None |
793 | + |
794 | + return dynamic_linker_path |
795 | + |
796 | + def _set_machine(self, target_deb_arch): |
797 | + """Set the target machine with the given deb arch""" |
798 | + self.__platform_arch = _get_platform_architecture() |
799 | + if not target_deb_arch: |
800 | + self.__target_machine = self.__platform_arch |
801 | + else: |
802 | + self.__target_machine = _find_machine(target_deb_arch) |
803 | + LOGGER.info('Setting target machine to {%s}', target_deb_arch) |
804 | + self.__machine_info = _ARCH_TRANSLATIONS[self.__target_machine] |
805 | + |
806 | + |
807 | +def _find_machine(deb_arch): |
808 | + """Return a machine for the given deb arch""" |
809 | + for machine in _ARCH_TRANSLATIONS: |
810 | + if _ARCH_TRANSLATIONS[machine].get('deb', '') == deb_arch: |
811 | + return machine |
812 | + raise EnvironmentError( |
813 | + 'Cannot set machine from deb_arch {!r}'.format(deb_arch)) |
814 | |
815 | === renamed directory 'snappy_ecosystem_tests/snapd' => 'snappy_ecosystem_tests/helpers/snapd' |
816 | === added directory 'snappy_ecosystem_tests/helpers/store_apis' |
817 | === added file 'snappy_ecosystem_tests/helpers/store_apis/__init__.py' |
818 | --- snappy_ecosystem_tests/helpers/store_apis/__init__.py 1970-01-01 00:00:00 +0000 |
819 | +++ snappy_ecosystem_tests/helpers/store_apis/__init__.py 2017-02-20 17:48:06 +0000 |
820 | @@ -0,0 +1,19 @@ |
821 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
822 | + |
823 | +# |
824 | +# Snappy Ecosystem Tests |
825 | +# Copyright (C) 2017 Canonical |
826 | +# |
827 | +# This program is free software: you can redistribute it and/or modify |
828 | +# it under the terms of the GNU General Public License as published by |
829 | +# the Free Software Foundation, either version 3 of the License, or |
830 | +# (at your option) any later version. |
831 | +# |
832 | +# This program is distributed in the hope that it will be useful, |
833 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
834 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
835 | +# GNU General Public License for more details. |
836 | +# |
837 | +# You should have received a copy of the GNU General Public License |
838 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
839 | +# |
840 | |
841 | === added file 'snappy_ecosystem_tests/helpers/store_apis/errors.py' |
842 | --- snappy_ecosystem_tests/helpers/store_apis/errors.py 1970-01-01 00:00:00 +0000 |
843 | +++ snappy_ecosystem_tests/helpers/store_apis/errors.py 2017-02-20 17:48:06 +0000 |
844 | @@ -0,0 +1,371 @@ |
845 | +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- |
846 | +# |
847 | +# Copyright (C) 2017 Canonical Ltd |
848 | +# |
849 | +# This program is free software: you can redistribute it and/or modify |
850 | +# it under the terms of the GNU General Public License version 3 as |
851 | +# published by the Free Software Foundation. |
852 | +# |
853 | +# This program is distributed in the hope that it will be useful, |
854 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
855 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
856 | +# GNU General Public License for more details. |
857 | +# |
858 | +# You should have received a copy of the GNU General Public License |
859 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
860 | + |
861 | +"""All the store exceptions""" |
862 | + |
863 | +from simplejson.scanner import JSONDecodeError |
864 | + |
865 | + |
866 | +class StoreException(Exception): |
867 | + """Base class for all store exceptions. |
868 | + :cvar fmt: A format string that daughter classes override |
869 | + """ |
870 | + fmt = 'Daughter classes should redefine this' |
871 | + |
872 | + def __init__(self, **kwargs): |
873 | + super().__init__() |
874 | + for key, value in kwargs.items(): |
875 | + setattr(self, key, value) |
876 | + |
877 | + def __str__(self): |
878 | + return self.fmt.format([], **self.__dict__) |
879 | + |
880 | + |
881 | +class InvalidCredentialsError(StoreException): |
882 | + """Exception to be raised when credentials are invalid""" |
883 | + fmt = 'Invalid credentials: {message}.' |
884 | + |
885 | + def __init__(self, message): |
886 | + super().__init__(message=message) |
887 | + |
888 | + |
889 | +class SnapNotFoundError(StoreException): |
890 | + """Exception to be raised when a snap is not found in the store""" |
891 | + __FMT_ARCH_CHANNEL = ( |
892 | + 'Snap {name!r} for {arch!r} cannot be found in the {channel!r} ' |
893 | + 'channel.') |
894 | + __FMT_CHANNEL = 'Snap {name!r} was not found in the {channel!r} channel.' |
895 | + __FMT_SERIES_ARCH = ( |
896 | + 'Snap {name!r} for {arch!r} was not found in {series!r} series.') |
897 | + __FMT_SERIES = 'Snap {name!r} was not found in {series!r} series.' |
898 | + |
899 | + fmt = 'Snap {name!r} was not found.' |
900 | + |
901 | + def __init__(self, name, channel=None, arch=None, series=None): |
902 | + if channel and arch: |
903 | + self.fmt = self.__FMT_ARCH_CHANNEL |
904 | + elif channel: |
905 | + self.fmt = self.__FMT_CHANNEL |
906 | + elif series and arch: |
907 | + self.fmt = self.__FMT_SERIES_ARCH |
908 | + elif series: |
909 | + self.fmt = self.__FMT_SERIES |
910 | + |
911 | + super().__init__(name=name, channel=channel, arch=arch, series=series) |
912 | + |
913 | + |
914 | +class SHAMismatchError(StoreException): |
915 | + """Exception to be raised when there is a mismatch in sha512""" |
916 | + fmt = 'SHA512 checksum for {path} is not {expected_sha}.' |
917 | + |
918 | + def __init__(self, path, expected_sha): |
919 | + super().__init__(path=path, expected_sha=expected_sha) |
920 | + |
921 | + |
922 | +class StoreAuthenticationError(StoreException): |
923 | + """Exception to be raised when there is an authentication error in the |
924 | + store""" |
925 | + fmt = 'Authentication error: {}.' |
926 | + |
927 | + def __init__(self, message): |
928 | + super().__init__(message=message) |
929 | + |
930 | + |
931 | +class StoreTwoFactorAuthenticationRequired(StoreAuthenticationError): |
932 | + """Exception to be raised when the credentials were provided but 2-factor |
933 | + authentication was not and is required. |
934 | + """ |
935 | + def __init__(self): |
936 | + super().__init__("Two-factor authentication required.") |
937 | + |
938 | + |
939 | +class StoreMacaroonNeedsRefreshError(StoreException): |
940 | + """Exception to be raised when a request needs to refresh |
941 | + the macaroon to be authenticated properly""" |
942 | + fmt = 'Authentication macaroon needs to be refreshed.' |
943 | + |
944 | + |
945 | +class DeveloperAgreementSignError(StoreException): |
946 | + """Exception to be raised when developer tries to do an operation |
947 | + in the store but he did not sign the Agreement yet""" |
948 | + fmt = ( |
949 | + 'There was an error while signing developer agreement.\n' |
950 | + 'Reason: {reason!r}\n' |
951 | + 'Text: {text!r}') |
952 | + |
953 | + def __init__(self, response): |
954 | + super().__init__(reason=response.reason, text=response.text) |
955 | + |
956 | + |
957 | +class NeedTermsSignedError(StoreException): |
958 | + """Exception to be raised when an operation needs the Developer |
959 | + Terms of Service to be signed before proceed""" |
960 | + fmt = ( |
961 | + 'Developer Terms of Service agreement must be signed ' |
962 | + 'before continuing: {message}') |
963 | + |
964 | + def __init__(self, message): |
965 | + super().__init__(message=message) |
966 | + |
967 | + |
968 | +class StoreAccountInformationError(StoreException): |
969 | + """Exception to be raised when there was not possible |
970 | + to fetch the user account information""" |
971 | + fmt = 'Error fetching account information from store: {error}' |
972 | + |
973 | + def __init__(self, response): |
974 | + error = '{} {}'.format(response.status_code, response.reason) |
975 | + extra = [] |
976 | + try: |
977 | + response_json = response.json() |
978 | + if 'error_list' in response_json: |
979 | + error = ' '.join( |
980 | + error['message'] for error in response_json['error_list']) |
981 | + extra = [ |
982 | + error['extra'] for error in response_json[ |
983 | + 'error_list'] if 'extra' in error] |
984 | + except JSONDecodeError: |
985 | + pass |
986 | + super().__init__(error=error, extra=extra) |
987 | + |
988 | + |
989 | +class StoreKeyRegistrationError(StoreException): |
990 | + """Exception to be raised when there was a problem |
991 | + registering a key in the store""" |
992 | + fmt = 'Key registration failed: {error}' |
993 | + |
994 | + def __init__(self, response): |
995 | + error = '{} {}'.format(response.status_code, response.reason) |
996 | + try: |
997 | + response_json = response.json() |
998 | + if 'error_list' in response_json: |
999 | + error = ' '.join( |
1000 | + error['message'] for error in response_json['error_list']) |
1001 | + except JSONDecodeError: |
1002 | + pass |
1003 | + super().__init__(error=error) |
1004 | + |
1005 | + |
1006 | +class StoreRegistrationError(StoreException): |
1007 | + """Exception to be raised when there was a problem registering |
1008 | + a snap name""" |
1009 | + __FMT_ALREADY_REGISTERED = ( |
1010 | + 'The name {snap_name!r} is already taken.\n\n' |
1011 | + 'We can if needed rename snaps to ensure they match the expectations ' |
1012 | + 'of most users. If you are the publisher most users expect for ' |
1013 | + '{snap_name!r} then claim the name at {register_name_url!r}') |
1014 | + |
1015 | + __FMT_ALREADY_OWNED = 'You already own the name {snap_name!r}.' |
1016 | + |
1017 | + __FMT_RESERVED = ( |
1018 | + 'The name {snap_name!r} is reserved.\n\n' |
1019 | + 'If you are the publisher most users expect for ' |
1020 | + '{snap_name!r} then please claim the name at {register_name_url!r}') |
1021 | + |
1022 | + __FMT_RETRY_WAIT = ( |
1023 | + 'You must wait {retry_after} seconds before trying to register ' |
1024 | + 'your next snap.') |
1025 | + |
1026 | + fmt = 'Registration failed.' |
1027 | + |
1028 | + __error_messages = { |
1029 | + 'already_registered': __FMT_ALREADY_REGISTERED, |
1030 | + 'already_owned': __FMT_ALREADY_OWNED, |
1031 | + 'reserved_name': __FMT_RESERVED, |
1032 | + 'register_window': __FMT_RETRY_WAIT, |
1033 | + } |
1034 | + |
1035 | + def __init__(self, snap_name, response): |
1036 | + try: |
1037 | + response_json = response.json() |
1038 | + except JSONDecodeError: |
1039 | + response_json = {} |
1040 | + |
1041 | + error_code = response_json.get('code') |
1042 | + if error_code: |
1043 | + # we default to self.fmt in case error_code is not mapped yet. |
1044 | + self.fmt = self.__error_messages.get(error_code, self.fmt) |
1045 | + |
1046 | + super().__init__(snap_name=snap_name, **response_json) |
1047 | + |
1048 | + |
1049 | +class StoreUploadError(StoreException): |
1050 | + """Exception to be raised when there was a problem |
1051 | + uploading a snap to the store""" |
1052 | + fmt = ( |
1053 | + 'There was an error uploading the package.\n' |
1054 | + 'Reason: {reason!r}\n' |
1055 | + 'Text: {text!r}') |
1056 | + |
1057 | + def __init__(self, response): |
1058 | + super().__init__(reason=response.reason, text=response.text) |
1059 | + |
1060 | + |
1061 | +class StorePushError(StoreException): |
1062 | + """Exception to be raised when there was a problem |
1063 | + pushing a snap to the store""" |
1064 | + __FMT_NOT_REGISTERED = ( |
1065 | + 'You are not the publisher or allowed to push revisions for this ' |
1066 | + 'snap. To become the publisher, run `snapcraft register {snap_name}` ' |
1067 | + 'and try to push again.') |
1068 | + |
1069 | + fmt = 'Received {status_code!r}: {text!r}' |
1070 | + |
1071 | + def __init__(self, snap_name, response): |
1072 | + try: |
1073 | + response_json = response.json() |
1074 | + except (AttributeError, JSONDecodeError): |
1075 | + response_json = {} |
1076 | + |
1077 | + if response.status_code == 404: |
1078 | + self.fmt = self.__FMT_NOT_REGISTERED |
1079 | + elif response.status_code == 401 or response.status_code == 403: |
1080 | + try: |
1081 | + response_json['text'] = response.text |
1082 | + except AttributeError: |
1083 | + response_json['text'] = 'error while pushing' |
1084 | + |
1085 | + super().__init__(snap_name=snap_name, status_code=response.status_code, |
1086 | + **response_json) |
1087 | + |
1088 | + |
1089 | +class StoreReviewError(StoreException): |
1090 | + """Exception to be raised when there was a problem in the snap |
1091 | + that prevents it to be published/updated""" |
1092 | + __FMT_NEED_MANUAL_REVIEW = ( |
1093 | + 'Publishing checks failed.\n' |
1094 | + 'To release this to stable channel please request a review on ' |
1095 | + 'the snapcraft list.\n' |
1096 | + 'Use devmode in the edge or beta channels to disable confinement.') |
1097 | + |
1098 | + __FMT_PROCESSING_ERROR = ( |
1099 | + 'There has been a problem while analyzing the snap, check the snap ' |
1100 | + 'and try to push again.') |
1101 | + |
1102 | + __messages = { |
1103 | + 'need_manual_review': __FMT_NEED_MANUAL_REVIEW, |
1104 | + 'processing_error': __FMT_PROCESSING_ERROR, |
1105 | + } |
1106 | + |
1107 | + def __init__(self, result): |
1108 | + self.fmt = self.__messages[result['code']] |
1109 | + super().__init__() |
1110 | + |
1111 | + |
1112 | +class StoreReleaseError(StoreException): |
1113 | + """Exception to be raised when the snap could not be released""" |
1114 | + __FMT_NOT_REGISTERED = ( |
1115 | + 'Sorry, try `snapcraft register {snap_name}` before trying to ' |
1116 | + 'release or choose an existing revision.') |
1117 | + |
1118 | + fmt = 'Received {status_code!r}: {text!r}' |
1119 | + |
1120 | + def __init__(self, snap_name, response): |
1121 | + try: |
1122 | + response_json = response.json() |
1123 | + except (AttributeError, JSONDecodeError): |
1124 | + response_json = {} |
1125 | + |
1126 | + if response.status_code == 404: |
1127 | + self.fmt = self.__FMT_NOT_REGISTERED |
1128 | + elif response.status_code == 401 or response.status_code == 403: |
1129 | + try: |
1130 | + response_json['text'] = response.text |
1131 | + except AttributeError: |
1132 | + response_json['text'] = 'error while releasing' |
1133 | + elif 'errors' in response_json: |
1134 | + self.fmt = '{errors}' |
1135 | + |
1136 | + super().__init__(snap_name=snap_name, status_code=response.status_code, |
1137 | + **response_json) |
1138 | + |
1139 | + |
1140 | +class StoreValidationError(StoreException): |
1141 | + """Exception to be raised when there was a validation |
1142 | + problem in the store""" |
1143 | + fmt = 'Received error {status_code!r}: {text!r}' |
1144 | + |
1145 | + def __init__(self, response, message=None): |
1146 | + try: |
1147 | + response_json = response.json() |
1148 | + response_json['text'] = response.json()['error_list'][0]['message'] |
1149 | + except (AttributeError, JSONDecodeError): |
1150 | + response_json = {'text': message or response} |
1151 | + |
1152 | + super().__init__(status_code=response.status_code, |
1153 | + **response_json) |
1154 | + |
1155 | + |
1156 | +class StoreSnapBuildError(StoreException): |
1157 | + """Exception to be raised when a snap build assertion fails""" |
1158 | + fmt = 'Could not assert build: {error}' |
1159 | + |
1160 | + def __init__(self, response): |
1161 | + error = '{} {}'.format(response.status_code, response.reason) |
1162 | + try: |
1163 | + response_json = response.json() |
1164 | + if 'error_list' in response_json: |
1165 | + error = ' '.join( |
1166 | + error['message'] for error in response_json['error_list']) |
1167 | + except JSONDecodeError: |
1168 | + pass |
1169 | + |
1170 | + super().__init__(error=error) |
1171 | + |
1172 | + |
1173 | +class StoreSnapHistoryError(StoreException): |
1174 | + """Exception to be raised when there was a problem fetching a snap |
1175 | + history""" |
1176 | + fmt = ( |
1177 | + 'Error fetching history of snap id {snap_id!r} for {arch!r} ' |
1178 | + 'in {series!r} series: {error}.') |
1179 | + |
1180 | + def __init__(self, response, snap_id, series, arch): |
1181 | + error = '{} {}'.format(response.status_code, response.reason) |
1182 | + try: |
1183 | + response_json = response.json() |
1184 | + if 'error_list' in response_json: |
1185 | + error = ' '.join( |
1186 | + error['message'] for error in response_json['error_list']) |
1187 | + except JSONDecodeError: |
1188 | + pass |
1189 | + |
1190 | + super().__init__( |
1191 | + snap_id=snap_id, arch=arch or 'any arch', |
1192 | + series=series or 'any', error=error) |
1193 | + |
1194 | + |
1195 | +class StoreSnapStatusError(StoreSnapHistoryError): |
1196 | + """Exception to be raised when there was a problem fetching a snap |
1197 | + status""" |
1198 | + fmt = ( |
1199 | + 'Error fetching status of snap id {snap_id!r} for {arch!r} ' |
1200 | + 'in {series!r} series: {error}.') |
1201 | + |
1202 | + |
1203 | +class StoreChannelClosingError(StoreException): |
1204 | + """Exception to be raised when there was a problem fetching a snap |
1205 | + history""" |
1206 | + fmt = 'Could not close channel: {error}' |
1207 | + |
1208 | + def __init__(self, response): |
1209 | + try: |
1210 | + _e = response.json()['error_list'][0] |
1211 | + error = '{}'.format(_e['message']) |
1212 | + except (JSONDecodeError, KeyError, IndexError): |
1213 | + error = '{} {}'.format( |
1214 | + response.status_code, response.reason) |
1215 | + super().__init__(error=error) |
1216 | |
1217 | === added file 'snappy_ecosystem_tests/helpers/store_apis/rest_apis.py' |
1218 | --- snappy_ecosystem_tests/helpers/store_apis/rest_apis.py 1970-01-01 00:00:00 +0000 |
1219 | +++ snappy_ecosystem_tests/helpers/store_apis/rest_apis.py 2017-02-20 17:48:06 +0000 |
1220 | @@ -0,0 +1,880 @@ |
1221 | +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- |
1222 | +# |
1223 | +# Copyright (C) 2017 Canonical Ltd |
1224 | +# |
1225 | +# This program is free software: you can redistribute it and/or modify |
1226 | +# it under the terms of the GNU General Public License version 3 as |
1227 | +# published by the Free Software Foundation. |
1228 | +# |
1229 | +# This program is distributed in the hope that it will be useful, |
1230 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
1231 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
1232 | +# GNU General Public License for more details. |
1233 | +# |
1234 | +# You should have received a copy of the GNU General Public License |
1235 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1236 | + |
1237 | +"""Clients needed to interact with the Snap Store""" |
1238 | + |
1239 | +import contextlib |
1240 | +import hashlib |
1241 | +import itertools |
1242 | +import json |
1243 | +import logging |
1244 | +import os |
1245 | +import urllib.parse |
1246 | +from queue import Queue |
1247 | +from threading import Thread |
1248 | +from time import sleep |
1249 | + |
1250 | +import pymacaroons |
1251 | +import requests |
1252 | +from progressbar import ( |
1253 | + AnimatedMarker, |
1254 | + ProgressBar, |
1255 | + UnknownLength, |
1256 | +) |
1257 | +from simplejson.scanner import JSONDecodeError |
1258 | +from snappy_ecosystem_tests.commons.config import CONFIG_STACK |
1259 | + |
1260 | +from snappy_ecosystem_tests.helpers.snapcraft import constants |
1261 | +from snappy_ecosystem_tests.helpers.snapcraft.config import SnapcraftConfig |
1262 | +from snappy_ecosystem_tests.helpers.snapcraft.indicators import ( |
1263 | + download_requests_stream |
1264 | +) |
1265 | +from snappy_ecosystem_tests.helpers.snapcraft.options import ProjectOptions |
1266 | +from snappy_ecosystem_tests.helpers.store_apis import errors |
1267 | +from snappy_ecosystem_tests.helpers.store_apis.upload import upload_files |
1268 | + |
1269 | +LOGGER = logging.getLogger(__name__) |
1270 | + |
1271 | +JSON_CONTENT_TYPE = {'Content-Type': 'application/json'} |
1272 | +JSON_ACCEPT = {'Accept': 'application/json'} |
1273 | +JSON_HEADERS = dict(JSON_CONTENT_TYPE, **JSON_ACCEPT) |
1274 | + |
1275 | + |
1276 | +def _macaroon_auth(conf): |
1277 | + """Format a macaroon and its associated discharge. |
1278 | + :return: A string suitable to use in an Authorization header. |
1279 | + """ |
1280 | + root_macaroon_raw = conf.get('macaroon') |
1281 | + if root_macaroon_raw is None: |
1282 | + raise errors.InvalidCredentialsError( |
1283 | + 'Root macaroon not in the config file') |
1284 | + unbound_raw = conf.get('unbound_discharge') |
1285 | + if unbound_raw is None: |
1286 | + raise errors.InvalidCredentialsError( |
1287 | + 'Unbound discharge not in the config file') |
1288 | + |
1289 | + root_macaroon = _deserialize_macaroon(root_macaroon_raw) |
1290 | + unbound = _deserialize_macaroon(unbound_raw) |
1291 | + bound = root_macaroon.prepare_for_request(unbound) |
1292 | + discharge_macaroon_raw = bound.serialize() |
1293 | + auth = 'Macaroon root={}, discharge={}'.format( |
1294 | + root_macaroon_raw, discharge_macaroon_raw) |
1295 | + return auth |
1296 | + |
1297 | + |
1298 | +def _deserialize_macaroon(value): |
1299 | + """Deserialize the given macaroon and return it |
1300 | + :raise InvalidCredentialsError: when macaroon cannot be deserialized |
1301 | + """ |
1302 | + try: |
1303 | + return pymacaroons.Macaroon.deserialize(value) |
1304 | + except: |
1305 | + raise errors.InvalidCredentialsError('Failed to deserialize macaroon') |
1306 | + |
1307 | + |
1308 | +class Client: |
1309 | + """A base class to define clients for the ols servers. |
1310 | + This is a simple wrapper around requests.Session so we inherit all good |
1311 | + bits while providing a simple point for tests to override when needed. |
1312 | + """ |
1313 | + |
1314 | + def __init__(self, conf, root_url): |
1315 | + self.conf = conf |
1316 | + self.root_url = root_url |
1317 | + self.session = requests.Session() |
1318 | + |
1319 | + def request(self, method, url, params=None, headers=None, **kwargs): |
1320 | + """Overriding base class to handle the root url.""" |
1321 | + # Note that url may be absolute in which case 'root_url' is ignored by |
1322 | + # urljoin. |
1323 | + if headers is None: |
1324 | + headers = JSON_HEADERS |
1325 | + final_url = urllib.parse.urljoin(self.root_url, url) |
1326 | + response = self.session.request( |
1327 | + method, final_url, headers=headers, |
1328 | + params=params, **kwargs) |
1329 | + return response |
1330 | + |
1331 | + def get(self, url, **kwargs): |
1332 | + """Do a GET request and return the response""" |
1333 | + return self.request('GET', url, **kwargs) |
1334 | + |
1335 | + def post(self, url, **kwargs): |
1336 | + """Do a POST request and return the response""" |
1337 | + return self.request('POST', url, **kwargs) |
1338 | + |
1339 | + def put(self, url, **kwargs): |
1340 | + """Do a PUT request and return the response""" |
1341 | + return self.request('PUT', url, **kwargs) |
1342 | + |
1343 | + |
1344 | +class Store: |
1345 | + """It is an entry point for all the REST operations in the store. |
1346 | + It will call the corresponding client to resolve the request. |
1347 | + It also takes care about refreshing the macaroon if necessary |
1348 | + in each request. |
1349 | + """ |
1350 | + |
1351 | + ACLs = ('package_upload', 'package_access') |
1352 | + |
1353 | + def __init__(self): |
1354 | + self.conf = SnapcraftConfig() |
1355 | + self.sso = SSOClient(self.conf) |
1356 | + self.cpi = SnapIndexClient(self.conf) |
1357 | + self.updown = UpDownClient(self.conf) |
1358 | + self.sca = SCAClient(self.conf) |
1359 | + |
1360 | + def login(self, email, password, one_time_password=None, acls=ACLs, |
1361 | + packages=None, channels=None, save=True): |
1362 | + """Log in via the Ubuntu One SSO API.""" |
1363 | + # Ask the store for the needed capabilities to be associated with the |
1364 | + # macaroon. |
1365 | + macaroon = self.sca.get_macaroon(acls, packages, channels) |
1366 | + caveat_id = self._extract_caveat_id(macaroon) |
1367 | + unbound_discharge = self.sso.get_unbound_discharge( |
1368 | + email, password, one_time_password, caveat_id) |
1369 | + # The macaroon has been discharged, save it in the config |
1370 | + self.conf.set('macaroon', macaroon) |
1371 | + self.conf.set('unbound_discharge', unbound_discharge) |
1372 | + if save: |
1373 | + self.conf.save() |
1374 | + |
1375 | + @property |
1376 | + def config(self): |
1377 | + """Get current config""" |
1378 | + return self.conf |
1379 | + |
1380 | + def load_config(self): |
1381 | + """Load current config""" |
1382 | + self.conf.load() |
1383 | + |
1384 | + def _extract_caveat_id(self, root_macaroon): |
1385 | + """Extract caveat id for the given macaroon |
1386 | + :raise InvalidCredentialsError: when given macaroon is not valid |
1387 | + """ |
1388 | + macaroon = pymacaroons.Macaroon.deserialize(root_macaroon) |
1389 | + # macaroons are all bytes, never strings |
1390 | + sso_host = urllib.parse.urlparse(self.sso.root_url).netloc |
1391 | + for caveat in macaroon.caveats: |
1392 | + if caveat.location == sso_host: |
1393 | + return caveat.caveat_id |
1394 | + raise errors.InvalidCredentialsError('Invalid root macaroon') |
1395 | + |
1396 | + def logout(self): |
1397 | + """Logout from Store client""" |
1398 | + self.conf.clear() |
1399 | + self.conf.save() |
1400 | + |
1401 | + def _refresh_if_necessary(self, func, *args, **kwargs): |
1402 | + """Make a request, refreshing macaroons if necessary.""" |
1403 | + try: |
1404 | + return func(*args, **kwargs) |
1405 | + except errors.StoreMacaroonNeedsRefreshError: |
1406 | + unbound_discharge = self.sso.refresh_unbound_discharge( |
1407 | + self.conf.get('unbound_discharge')) |
1408 | + self.conf.set('unbound_discharge', unbound_discharge) |
1409 | + self.conf.save() |
1410 | + return func(*args, **kwargs) |
1411 | + |
1412 | + def get_account_information(self): |
1413 | + """Get current account information""" |
1414 | + return self._refresh_if_necessary(self.sca.get_account_information) |
1415 | + |
1416 | + def register_key(self, account_key_request): |
1417 | + """Register a key for the current user""" |
1418 | + return self._refresh_if_necessary( |
1419 | + self.sca.register_key, account_key_request) |
1420 | + |
1421 | + def register(self, snap_name, is_private=False): |
1422 | + """Register a package name for the current user |
1423 | + First refresh macaroon if necessary""" |
1424 | + return self._refresh_if_necessary( |
1425 | + self.sca.register, snap_name, is_private, constants.DEFAULT_SERIES) |
1426 | + |
1427 | + def push_precheck(self, snap_name): |
1428 | + """Do a snap push as a pre-check (dry_run: do not actually push) |
1429 | + First refresh macaroon if necessary |
1430 | + """ |
1431 | + return self._refresh_if_necessary( |
1432 | + self.sca.snap_push_precheck, snap_name) |
1433 | + |
1434 | + def push_snap_build(self, snap_id, snap_build): |
1435 | + """Do a snap push |
1436 | + First refresh macaroon if necessary |
1437 | + """ |
1438 | + return self._refresh_if_necessary( |
1439 | + self.sca.push_snap_build, snap_id, snap_build) |
1440 | + |
1441 | + def upload(self, snap_name, snap_filename): |
1442 | + """Upload a snap file. |
1443 | + :raise InvalidCredentialsError when no unbound_discharge |
1444 | + is found in current config |
1445 | + """ |
1446 | + if not self.conf.get('unbound_discharge'): |
1447 | + raise errors.InvalidCredentialsError( |
1448 | + 'Unbound discharge not in the config file') |
1449 | + |
1450 | + updown_data = upload_files(snap_filename, self.updown) |
1451 | + |
1452 | + return self._refresh_if_necessary( |
1453 | + self.sca.snap_push_metadata, snap_name, updown_data) |
1454 | + |
1455 | + def release(self, snap_name, revision, channels): |
1456 | + """Release a snap revision to the given channels |
1457 | + First refresh macaroon if necessary |
1458 | + """ |
1459 | + return self._refresh_if_necessary( |
1460 | + self.sca.snap_release, snap_name, revision, channels) |
1461 | + |
1462 | + def get_snap_history(self, snap_name, series=None, arch=None): |
1463 | + """Get a snap history. First refresh macaroon if necessary |
1464 | + :raise: SnapNotFoundError if the snap does not belong to |
1465 | + the current user |
1466 | + or if it is not found. |
1467 | + """ |
1468 | + if series is None: |
1469 | + series = constants.DEFAULT_SERIES |
1470 | + |
1471 | + account_info = self.get_account_information() |
1472 | + try: |
1473 | + snap_id = account_info['snaps'][series][snap_name]['snap-id'] |
1474 | + except KeyError: |
1475 | + raise errors.SnapNotFoundError(snap_name, series=series, arch=arch) |
1476 | + |
1477 | + response = self._refresh_if_necessary( |
1478 | + self.sca.snap_history, snap_id, series, arch) |
1479 | + |
1480 | + if not response: |
1481 | + raise errors.SnapNotFoundError(snap_name, series=series, arch=arch) |
1482 | + |
1483 | + return response |
1484 | + |
1485 | + def get_snap_status(self, snap_name, series=None, arch=None): |
1486 | + """Get a snap status. First refresh macaroon if necessary |
1487 | + :raise: SnapNotFoundError if the snap does not belong |
1488 | + to the current user |
1489 | + or if it is not found. |
1490 | + """ |
1491 | + if series is None: |
1492 | + series = constants.DEFAULT_SERIES |
1493 | + |
1494 | + account_info = self.get_account_information() |
1495 | + try: |
1496 | + snap_id = account_info['snaps'][series][snap_name]['snap-id'] |
1497 | + except KeyError: |
1498 | + raise errors.SnapNotFoundError(snap_name, series=series, arch=arch) |
1499 | + |
1500 | + response = self._refresh_if_necessary( |
1501 | + self.sca.snap_status, snap_id, series, arch) |
1502 | + |
1503 | + if not response: |
1504 | + raise errors.SnapNotFoundError(snap_name, series=series, arch=arch) |
1505 | + |
1506 | + return response |
1507 | + |
1508 | + def close_channels(self, snap_id, channel_names): |
1509 | + """Close channels for the given snap_id. |
1510 | + First refresh macaroon if necessary""" |
1511 | + return self._refresh_if_necessary( |
1512 | + self.sca.close_channels, snap_id, channel_names) |
1513 | + |
1514 | + def download(self, snap_name, channel, download_path, arch=None): |
1515 | + """Download a snap and store it locally |
1516 | + :param snap_name: the snap name |
1517 | + :param channel: the channel name |
1518 | + :param download_path: the local path to download the snap |
1519 | + :param arch: the snap architecture |
1520 | + """ |
1521 | + if arch is None: |
1522 | + arch = ProjectOptions().deb_arch |
1523 | + |
1524 | + package = self.cpi.get_package(snap_name, channel, arch) |
1525 | + self._download_snap( |
1526 | + snap_name, download_path, |
1527 | + package['anon_download_url'], package['download_sha512']) |
1528 | + |
1529 | + def _download_snap(self, name, download_path, download_url, |
1530 | + expected_sha512): |
1531 | + """Download a snap and store it locally |
1532 | + :param name: the snap name |
1533 | + :param download_path: the local path to download the snap |
1534 | + :param download_url: the url to download the snap |
1535 | + :param expected_sha512: the snap expected sha256 |
1536 | + """ |
1537 | + if self._is_downloaded(download_path, expected_sha512): |
1538 | + LOGGER.info('Already downloaded %s at %s', name, download_path) |
1539 | + return |
1540 | + LOGGER.info('Downloading %s at %s', name, download_path) |
1541 | + request = self.cpi.get(download_url, stream=True) |
1542 | + request.raise_for_status() |
1543 | + download_requests_stream(request, download_path) |
1544 | + |
1545 | + if self._is_downloaded(download_path, expected_sha512): |
1546 | + LOGGER.info('Successfully downloaded %s at %s', name, |
1547 | + download_path) |
1548 | + else: |
1549 | + raise errors.SHAMismatchError(download_path, expected_sha512) |
1550 | + |
1551 | + @staticmethod |
1552 | + def _is_downloaded(path, expected_sha512): |
1553 | + """Return True if the snap is already downloaded in the local machine |
1554 | + :param path: the path to look for the snap |
1555 | + :param expected_sha512: the expected snap sha512 |
1556 | + """ |
1557 | + if not os.path.exists(path): |
1558 | + return False |
1559 | + |
1560 | + file_sum = hashlib.sha512() |
1561 | + with open(path, 'rb') as _f: |
1562 | + for file_chunk in iter( |
1563 | + lambda: _f.read(file_sum.block_size * 128), b''): |
1564 | + file_sum.update(file_chunk) |
1565 | + return expected_sha512 == file_sum.hexdigest() |
1566 | + |
1567 | + def push_validation(self, snap_id, assertion): |
1568 | + """Push a snap validation/assertion to the store |
1569 | + :param snap_id: the snap id |
1570 | + :param assertion: the snap assertion |
1571 | + """ |
1572 | + return self.sca.push_validation(snap_id, assertion) |
1573 | + |
1574 | + def get_validations(self, snap_id): |
1575 | + """Get current snap validations/assertions""" |
1576 | + return self.sca.get_validations(snap_id) |
1577 | + |
1578 | + def sign_developer_agreement(self, latest_tos_accepted=False): |
1579 | + """Sign developer agreement to be able to do operations in the store""" |
1580 | + return self.sca.sign_developer_agreement(latest_tos_accepted) |
1581 | + |
1582 | + |
1583 | +class SSOClient(Client): |
1584 | + """The Single Sign On server deals with authentication. |
1585 | + It is used directly or indirectly by other servers. |
1586 | + """ |
1587 | + def __init__(self, conf): |
1588 | + super().__init__(conf, os.environ.get( |
1589 | + 'UBUNTU_SSO_API_ROOT_URL', |
1590 | + CONFIG_STACK.config.get('production_urls', 'sso'))) |
1591 | + |
1592 | + def get_unbound_discharge(self, email, password, one_time_password, |
1593 | + caveat_id): |
1594 | + """Get the unbound discharge for the caveat_id. |
1595 | + :param email: user email |
1596 | + :param password: user password |
1597 | + :param one_time_password: the one time password |
1598 | + :param caveat_id: the caveat id to get the unbound discharge |
1599 | + :raise StoreTwoFactorAuthenticationRequired: |
1600 | + if response is unauthorized and two-factor is required |
1601 | + :raise StoreAuthenticationError: if failed to get unbound discharge |
1602 | + """ |
1603 | + data = dict(email=email, password=password, |
1604 | + caveat_id=caveat_id) |
1605 | + if one_time_password: |
1606 | + data['otp'] = one_time_password |
1607 | + response = self.post( |
1608 | + 'tokens/discharge', data=json.dumps(data), |
1609 | + headers=JSON_HEADERS) |
1610 | + try: |
1611 | + response_json = response.json() |
1612 | + except JSONDecodeError: |
1613 | + response_json = {} |
1614 | + if response.ok: |
1615 | + return response_json['discharge_macaroon'] |
1616 | + else: |
1617 | + if (response.status_code == requests.codes.get('unauthorized') and |
1618 | + any(error.get('code') == 'twofactor-required' |
1619 | + for error in response_json.get('error_list', []))): |
1620 | + raise errors.StoreTwoFactorAuthenticationRequired() |
1621 | + else: |
1622 | + raise errors.StoreAuthenticationError( |
1623 | + 'Failed to get unbound discharge: {}'.format( |
1624 | + response.text)) |
1625 | + |
1626 | + def refresh_unbound_discharge(self, unbound_discharge): |
1627 | + """Refresh the given unbound discharge. |
1628 | + :raise StoreAuthenticationError: if fails to refresh the |
1629 | + unbound discharge. |
1630 | + """ |
1631 | + data = {'discharge_macaroon': unbound_discharge} |
1632 | + response = self.post( |
1633 | + 'tokens/refresh', data=json.dumps(data), |
1634 | + headers=JSON_HEADERS) |
1635 | + if response.ok: |
1636 | + return response.json()['discharge_macaroon'] |
1637 | + else: |
1638 | + raise errors.StoreAuthenticationError( |
1639 | + 'Failed to refresh unbound discharge: {}'.format( |
1640 | + response.text)) |
1641 | + |
1642 | + |
1643 | +class SnapIndexClient(Client): |
1644 | + """The Click Package Index knows everything about existing snaps. |
1645 | + https://wiki.ubuntu.com/AppStore/Interfaces/ClickPackageIndex is the |
1646 | + canonical reference. |
1647 | + """ |
1648 | + def __init__(self, conf): |
1649 | + super().__init__(conf, os.environ.get( |
1650 | + 'UBUNTU_STORE_SEARCH_ROOT_URL', |
1651 | + CONFIG_STACK.config.get('production_urls', 'search'))) |
1652 | + |
1653 | + def get_default_headers(self): |
1654 | + """Return default headers for CPI requests. |
1655 | + Tries to build an 'Authorization' header with local credentials |
1656 | + if they are available. |
1657 | + Also pin specific branded store if `SNAPCRAFT_UBUNTU_STORE` |
1658 | + environment is set. |
1659 | + """ |
1660 | + headers = {} |
1661 | + |
1662 | + with contextlib.suppress(errors.InvalidCredentialsError): |
1663 | + headers['Authorization'] = _macaroon_auth(self.conf) |
1664 | + |
1665 | + branded_store = os.getenv('SNAPCRAFT_UBUNTU_STORE') |
1666 | + if branded_store: |
1667 | + headers['X-Ubuntu-Store'] = branded_store |
1668 | + |
1669 | + return headers |
1670 | + |
1671 | + def get_package(self, snap_name, channel, arch=None): |
1672 | + """Get snap details. |
1673 | + :param snap_name: the snap name |
1674 | + :param channel: the snap channel |
1675 | + :param arch: the snap architecture |
1676 | + """ |
1677 | + headers = self.get_default_headers() |
1678 | + headers.update({ |
1679 | + 'Accept': 'application/hal+json', |
1680 | + 'X-Ubuntu-Release': constants.DEFAULT_SERIES, |
1681 | + }) |
1682 | + if arch: |
1683 | + headers['X-Ubuntu-Architecture'] = arch |
1684 | + |
1685 | + params = { |
1686 | + 'channel': channel, |
1687 | + 'fields': 'status,anon_download_url,download_url,' |
1688 | + 'download_sha512,snap_id,release', |
1689 | + } |
1690 | + LOGGER.info('Getting details for %s', snap_name) |
1691 | + url = 'api/v1/snaps/details/{}'.format(snap_name) |
1692 | + resp = super().get(url, headers=headers, params=params) |
1693 | + if resp.status_code != 200: |
1694 | + raise errors.SnapNotFoundError(snap_name, channel, arch) |
1695 | + return resp.json() |
1696 | + |
1697 | + |
1698 | +class UpDownClient(Client): |
1699 | + """The Up/Down server provide upload/download snap capabilities.""" |
1700 | + |
1701 | + def __init__(self, conf): |
1702 | + super().__init__(conf, os.environ.get( |
1703 | + 'UBUNTU_STORE_UPLOAD_ROOT_URL', |
1704 | + CONFIG_STACK.config.get('production_urls', 'upload'))) |
1705 | + |
1706 | + def upload(self, monitor): |
1707 | + """Upload a snap capability""" |
1708 | + return self.post( |
1709 | + urllib.parse.urljoin(self.root_url, 'unscanned-upload/'), |
1710 | + data=monitor, |
1711 | + headers=dict({'Content-Type': monitor.content_type}, |
1712 | + **JSON_ACCEPT)) |
1713 | + |
1714 | + |
1715 | +class SCAClient(Client): |
1716 | + """The software center agent deals with managing snaps.""" |
1717 | + |
1718 | + def __init__(self, conf): |
1719 | + super().__init__(conf, os.environ.get( |
1720 | + 'UBUNTU_STORE_API_ROOT_URL', |
1721 | + CONFIG_STACK.config.get('production_urls', 'root_api'))) |
1722 | + |
1723 | + def get_macaroon(self, acls, packages=None, channels=None): |
1724 | + """Get a macaroon that is used to authenticate |
1725 | + request against the store |
1726 | + :param acls: list of permissions to request the macaroon for. |
1727 | + :param packages: list of packages to request the macaroon for. |
1728 | + :param channels: list of channels to request the macaroon for. |
1729 | + """ |
1730 | + data = {'permissions': acls} |
1731 | + if packages is not None: |
1732 | + data.update({'packages': packages}) |
1733 | + if channels is not None: |
1734 | + data.update({'channels': channels}) |
1735 | + headers = JSON_ACCEPT |
1736 | + response = self.post( |
1737 | + 'acl/', json=data, headers=headers) |
1738 | + if response.ok: |
1739 | + return response.json()['macaroon'] |
1740 | + else: |
1741 | + raise errors.StoreAuthenticationError('Failed to get macaroon') |
1742 | + |
1743 | + @staticmethod |
1744 | + def _needs_refreshed_response(response): |
1745 | + """Return True if the given response needs to refresh the macaroon. |
1746 | + :param response: the response to check if the |
1747 | + macaroon refresh is needed. |
1748 | + """ |
1749 | + return ( |
1750 | + response.status_code == requests.codes.get('unauthorized') and |
1751 | + response.headers.get('WWW-Authenticate') == ( |
1752 | + 'Macaroon needs_refresh=1')) |
1753 | + |
1754 | + def request(self, *args, **kwargs): |
1755 | + """Wrapper of client request that raises an exception for |
1756 | + the cases that the response macaroon needs to be refreshed |
1757 | + :raise StoreMacaroonNeedsRefreshError: |
1758 | + if the response needs the macaroon to be refreshed. |
1759 | + """ |
1760 | + response = super().request(*args, **kwargs) |
1761 | + if self._needs_refreshed_response(response): |
1762 | + raise errors.StoreMacaroonNeedsRefreshError() |
1763 | + return response |
1764 | + |
1765 | + def get_account_information(self): |
1766 | + """Get the current account information |
1767 | + :raise StoreAccountInformationError: if response is not ok status |
1768 | + """ |
1769 | + auth = _macaroon_auth(self.conf) |
1770 | + response = self.get( |
1771 | + 'account', |
1772 | + headers=dict({'Authorization': auth}, **JSON_ACCEPT)) |
1773 | + if response.ok: |
1774 | + return response.json() |
1775 | + else: |
1776 | + raise errors.StoreAccountInformationError(response) |
1777 | + |
1778 | + def register_key(self, account_key_request): |
1779 | + """Register a key for a user with the given request |
1780 | + :param account_key_request: the key to register |
1781 | + :raise StoreKeyRegistrationError |
1782 | + """ |
1783 | + data = {'account_key_request': account_key_request} |
1784 | + auth = _macaroon_auth(self.conf) |
1785 | + response = self.post( |
1786 | + 'account/account-key', data=json.dumps(data), |
1787 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1788 | + if not response.ok: |
1789 | + raise errors.StoreKeyRegistrationError(response) |
1790 | + |
1791 | + def register(self, snap_name, is_private, series): |
1792 | + """Register a snap name in the store |
1793 | + :param snap_name: the name to be registered |
1794 | + :param is_private: whether the snap name is private or not |
1795 | + :param series: the snap's series to be registered |
1796 | + """ |
1797 | + auth = _macaroon_auth(self.conf) |
1798 | + data = dict(snap_name=snap_name, is_private=is_private, |
1799 | + series=series) |
1800 | + response = self.post( |
1801 | + 'register-name/', data=json.dumps(data), |
1802 | + headers=dict({'Authorization': auth}, **JSON_ACCEPT)) |
1803 | + if not response.ok: |
1804 | + raise errors.StoreRegistrationError(snap_name, response) |
1805 | + |
1806 | + def snap_push_precheck(self, snap_name): |
1807 | + """Do a snap push pre-check (dry_run: do not actually push) |
1808 | + :raise StorePushError |
1809 | + """ |
1810 | + data = { |
1811 | + 'name': snap_name, |
1812 | + 'dry_run': True, |
1813 | + } |
1814 | + auth = _macaroon_auth(self.conf) |
1815 | + response = self.post( |
1816 | + 'snap-push/', data=json.dumps(data), |
1817 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1818 | + if not response.ok: |
1819 | + raise errors.StorePushError(data['name'], response) |
1820 | + |
1821 | + def snap_push_metadata(self, snap_name, updown_data): |
1822 | + """Push metadata fot the given snap |
1823 | + :param snap_name: the snap name which metadata want to be updated |
1824 | + :param updown_data: a dictionary that contains the metadata: upload_id, |
1825 | + binary_filesize, source_uploaded |
1826 | + :raise: StorePushError |
1827 | + """ |
1828 | + data = { |
1829 | + 'name': snap_name, |
1830 | + 'series': constants.DEFAULT_SERIES, |
1831 | + 'updown_id': updown_data['upload_id'], |
1832 | + 'binary_filesize': updown_data['binary_filesize'], |
1833 | + 'source_uploaded': updown_data['source_uploaded'], |
1834 | + } |
1835 | + auth = _macaroon_auth(self.conf) |
1836 | + response = self.post( |
1837 | + 'snap-push/', data=json.dumps(data), |
1838 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1839 | + if not response.ok: |
1840 | + raise errors.StorePushError(data['name'], response) |
1841 | + |
1842 | + return StatusTracker(response.json()['status_details_url']) |
1843 | + |
1844 | + def snap_release(self, snap_name, revision, channels): |
1845 | + """ |
1846 | + Release a snap revision to the given channel |
1847 | + :param snap_name: the snap name to be released |
1848 | + :param revision: the revision number to be released |
1849 | + :param channels: the channels in which release the snap |
1850 | + :raise: StoreReleaseError |
1851 | + """ |
1852 | + data = { |
1853 | + 'name': snap_name, |
1854 | + 'revision': str(revision), |
1855 | + 'channels': channels, |
1856 | + } |
1857 | + auth = _macaroon_auth(self.conf) |
1858 | + response = self.post( |
1859 | + 'snap-release/', data=json.dumps(data), |
1860 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1861 | + if not response.ok: |
1862 | + raise errors.StoreReleaseError(data['name'], response) |
1863 | + |
1864 | + response_json = response.json() |
1865 | + |
1866 | + return response_json |
1867 | + |
1868 | + def push_validation(self, snap_id, assertion): |
1869 | + """ |
1870 | + Push a validation/assertion to the store |
1871 | + :param snap_id: the snap_id of the snap |
1872 | + :param assertion: the validation/assertion to be pushed |
1873 | + :raise: JSONDecodeError, StoreValidationError |
1874 | + """ |
1875 | + data = { |
1876 | + 'assertion': assertion.decode('utf-8'), |
1877 | + } |
1878 | + auth = _macaroon_auth(self.conf) |
1879 | + response = self.put( |
1880 | + 'snaps/{}/validations'.format(snap_id), data=json.dumps(data), |
1881 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1882 | + if not response.ok: |
1883 | + raise errors.StoreValidationError(snap_id, response) |
1884 | + try: |
1885 | + response_json = response.json() |
1886 | + except JSONDecodeError: |
1887 | + message = ('Invalid response from the server when pushing ' |
1888 | + 'validations: {} {}').format( |
1889 | + response.status_code, response) |
1890 | + LOGGER.debug(message) |
1891 | + raise errors.StoreValidationError( |
1892 | + response, message='Invalid response from the server') |
1893 | + |
1894 | + return response_json |
1895 | + |
1896 | + def get_validations(self, snap_id): |
1897 | + """Get validations/assertion of a given snap |
1898 | + :param snap_id: the id of the snap to get the validations for. |
1899 | + :raise: JSONDecodeError, StoreValidationError |
1900 | + """ |
1901 | + auth = _macaroon_auth(self.conf) |
1902 | + response = self.get( |
1903 | + 'snaps/{}/validations'.format(snap_id), |
1904 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1905 | + if not response.ok: |
1906 | + raise errors.StoreValidationError(snap_id, response) |
1907 | + try: |
1908 | + response_json = response.json() |
1909 | + except JSONDecodeError: |
1910 | + message = ('Invalid response from the server when getting ' |
1911 | + 'validations: {} {}').format( |
1912 | + response.status_code, response) |
1913 | + LOGGER.debug(message) |
1914 | + raise errors.StoreValidationError( |
1915 | + response, message='Invalid response from the server') |
1916 | + |
1917 | + return response_json |
1918 | + |
1919 | + def push_snap_build(self, snap_id, snap_build): |
1920 | + """ |
1921 | + Push a snap build to the store |
1922 | + :param snap_id: the snap id |
1923 | + :param snap_build: the snap build to push |
1924 | + :raise: StoreSnapBuildError |
1925 | + """ |
1926 | + url = 'snaps/{}/builds'.format(snap_id) |
1927 | + data = json.dumps({"assertion": snap_build}) |
1928 | + |
1929 | + headers = dict({'Authorization': _macaroon_auth(self.conf)}, |
1930 | + **JSON_CONTENT_TYPE) |
1931 | + response = self.post(url, data=data, headers=headers) |
1932 | + if not response.ok: |
1933 | + raise errors.StoreSnapBuildError(response) |
1934 | + |
1935 | + def snap_history(self, snap_id, series=None, arch=None): |
1936 | + """ |
1937 | + Get a snap history |
1938 | + :param snap_id: the id of the snap to get the history for. |
1939 | + :param series: the series of the snap |
1940 | + :param arch: the architecture of the snap |
1941 | + :raise: StoreSnapHistoryError |
1942 | + """ |
1943 | + qs = {} |
1944 | + if series: |
1945 | + qs['series'] = series |
1946 | + if arch: |
1947 | + qs['arch'] = arch |
1948 | + url = 'snaps/' + snap_id + '/history' |
1949 | + if qs: |
1950 | + url += '?' + urllib.parse.urlencode(qs) |
1951 | + auth = _macaroon_auth(self.conf) |
1952 | + response = self.get( |
1953 | + url, |
1954 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1955 | + if not response.ok: |
1956 | + raise errors.StoreSnapHistoryError(response, snap_id, series, arch) |
1957 | + |
1958 | + response_json = response.json() |
1959 | + |
1960 | + return response_json |
1961 | + |
1962 | + def snap_status(self, snap_id, series=None, arch=None): |
1963 | + """ |
1964 | + Get a snap status |
1965 | + :param snap_id: the id of the snap to get the history for. |
1966 | + :param series: the series of the snap |
1967 | + :param arch: the architecture of the snap |
1968 | + :raise: StoreSnapStatusError |
1969 | + """ |
1970 | + qs = {} |
1971 | + if series: |
1972 | + qs['series'] = series |
1973 | + if arch: |
1974 | + qs['arch'] = arch |
1975 | + url = 'snaps/' + snap_id + '/status' |
1976 | + if qs: |
1977 | + url += '?' + urllib.parse.urlencode(qs) |
1978 | + auth = _macaroon_auth(self.conf) |
1979 | + response = self.get( |
1980 | + url, |
1981 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
1982 | + if not response.ok: |
1983 | + raise errors.StoreSnapStatusError(response, snap_id, series, arch) |
1984 | + |
1985 | + response_json = response.json() |
1986 | + |
1987 | + return response_json |
1988 | + |
1989 | + def close_channels(self, snap_id, channel_names): |
1990 | + """ |
1991 | + Close channel for a given snap |
1992 | + :param snap_id: the id of the snap |
1993 | + :param channel_names: the channels to be closed |
1994 | + :raise: StoreChannelClosingError |
1995 | + """ |
1996 | + url = 'snaps/{}/close'.format(snap_id) |
1997 | + data = { |
1998 | + 'channels': channel_names |
1999 | + } |
2000 | + headers = { |
2001 | + 'Authorization': _macaroon_auth(self.conf), |
2002 | + } |
2003 | + response = self.post(url, json=data, headers=headers) |
2004 | + if not response.ok: |
2005 | + raise errors.StoreChannelClosingError(response) |
2006 | + |
2007 | + try: |
2008 | + results = response.json() |
2009 | + return results['closed_channels'], results['channel_maps'] |
2010 | + except (JSONDecodeError, KeyError): |
2011 | + LOGGER.debug( |
2012 | + 'Invalid response from the server on channel closing:\n' |
2013 | + '%s %s\n%s', response.status_code, response.reason, |
2014 | + response.content) |
2015 | + raise errors.StoreChannelClosingError(response) |
2016 | + |
2017 | + def sign_developer_agreement(self, latest_tos_accepted=False): |
2018 | + """Sign developer agreement to be able to do operations in the store""" |
2019 | + auth = _macaroon_auth(self.conf) |
2020 | + data = {'latest_tos_accepted': latest_tos_accepted} |
2021 | + response = self.post( |
2022 | + 'agreement/', data=json.dumps(data), |
2023 | + headers=dict({'Authorization': auth}, **JSON_HEADERS)) |
2024 | + if not response.ok: |
2025 | + raise errors.DeveloperAgreementSignError(response) |
2026 | + return response.json() |
2027 | + |
2028 | + |
2029 | +class StatusTracker: |
2030 | + |
2031 | + """Class to track the status of an operation""" |
2032 | + |
2033 | + __messages = { |
2034 | + 'being_processed': 'Processing...', |
2035 | + 'ready_to_release': 'Ready to release!', |
2036 | + 'need_manual_review': 'Will need manual review...', |
2037 | + 'processing_error': 'Error while processing...', |
2038 | + } |
2039 | + |
2040 | + __error_codes = ( |
2041 | + 'processing_error', |
2042 | + 'need_manual_review', |
2043 | + ) |
2044 | + |
2045 | + def __init__(self, status_details_url): |
2046 | + self.__content = None |
2047 | + self.__status_details_url = status_details_url |
2048 | + |
2049 | + def track(self): |
2050 | + """Track the status""" |
2051 | + queue = Queue() |
2052 | + thread = Thread(target=self._update_status, args=(queue,)) |
2053 | + thread.start() |
2054 | + widgets = ['Processing...', AnimatedMarker()] |
2055 | + progress_indicator = ProgressBar(widgets=widgets, maxval=UnknownLength) |
2056 | + progress_indicator.start() |
2057 | + content = {} |
2058 | + for indicator_count in itertools.count(): |
2059 | + if not queue.empty(): |
2060 | + content = queue.get() |
2061 | + if isinstance(content, Exception): |
2062 | + raise Exception(content) |
2063 | + widgets[0] = self._get_message(content) |
2064 | + progress_indicator.update(indicator_count) |
2065 | + if content.get('processed'): |
2066 | + break |
2067 | + sleep(0.1) |
2068 | + progress_indicator.finish() |
2069 | + self.__content = content |
2070 | + return content |
2071 | + |
2072 | + def raise_for_code(self): |
2073 | + """Raise an exception if content has particular error codes""" |
2074 | + if any(self.__content['code'] == _k for _k in self.__error_codes): |
2075 | + raise errors.StoreReviewError(self.__content) |
2076 | + |
2077 | + def _get_message(self, content): |
2078 | + """Get a message from content""" |
2079 | + return self.__messages.get(content['code'], content['code']) |
2080 | + |
2081 | + def _update_status(self, queue): |
2082 | + """Update an operation status""" |
2083 | + for content in self._get_status(): |
2084 | + queue.put(content) |
2085 | + if content['processed']: |
2086 | + break |
2087 | + sleep(constants.SCAN_STATUS_POLL_DELAY) |
2088 | + |
2089 | + def _get_status(self): |
2090 | + """Get an operation status""" |
2091 | + connection_errors_allowed = 10 |
2092 | + while True: |
2093 | + try: |
2094 | + content = requests.get(self.__status_details_url).json() |
2095 | + except (requests.ConnectionError, requests.HTTPError) as _e: |
2096 | + if not connection_errors_allowed: |
2097 | + yield _e |
2098 | + content = {'processed': False, 'code': 'being_processed'} |
2099 | + connection_errors_allowed -= 1 |
2100 | + yield content |
2101 | |
2102 | === added file 'snappy_ecosystem_tests/helpers/store_apis/upload.py' |
2103 | --- snappy_ecosystem_tests/helpers/store_apis/upload.py 1970-01-01 00:00:00 +0000 |
2104 | +++ snappy_ecosystem_tests/helpers/store_apis/upload.py 2017-02-20 17:48:06 +0000 |
2105 | @@ -0,0 +1,97 @@ |
2106 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
2107 | + |
2108 | +# |
2109 | +# Snappy Ecosystem Tests |
2110 | +# Copyright (C) 2017 Canonical |
2111 | +# |
2112 | +# This program is free software: you can redistribute it and/or modify |
2113 | +# it under the terms of the GNU General Public License as published by |
2114 | +# the Free Software Foundation, either version 3 of the License, or |
2115 | +# (at your option) any later version. |
2116 | +# |
2117 | +# This program is distributed in the hope that it will be useful, |
2118 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
2119 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
2120 | +# GNU General Public License for more details. |
2121 | +# |
2122 | +# You should have received a copy of the GNU General Public License |
2123 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2124 | +# |
2125 | + |
2126 | +"""Helpers to upload binary files to the store""" |
2127 | + |
2128 | +import logging |
2129 | +import functools |
2130 | +import os |
2131 | + |
2132 | +from progressbar import ( |
2133 | + Bar, |
2134 | + Percentage, |
2135 | + ProgressBar, |
2136 | +) |
2137 | +from requests_toolbelt import (MultipartEncoder, MultipartEncoderMonitor) |
2138 | + |
2139 | +from snappy_ecosystem_tests.helpers.store_apis.errors import StoreUploadError |
2140 | + |
2141 | +LOGGER = logging.getLogger(__name__) |
2142 | + |
2143 | + |
2144 | +def _update_progress_bar(progress_bar, maximum_value, monitor): |
2145 | + """Update the progress bar status""" |
2146 | + if monitor.bytes_read <= maximum_value: |
2147 | + progress_bar.update(monitor.bytes_read) |
2148 | + |
2149 | + |
2150 | +def upload_files(binary_filename, updown_client): |
2151 | + """Upload a binary file to the Store. |
2152 | + Submit a file to the Store upload service and return the |
2153 | + corresponding upload_id. |
2154 | + """ |
2155 | + binary_file = None |
2156 | + try: |
2157 | + binary_file_size = os.path.getsize(binary_filename) |
2158 | + binary_file = open(binary_filename, 'rb') |
2159 | + encoder = MultipartEncoder( |
2160 | + fields={ |
2161 | + 'binary': ('filename', binary_file, 'application/octet-stream') |
2162 | + } |
2163 | + ) |
2164 | + |
2165 | + # Create a progress bar that looks like: Uploading foo [== ] 50% |
2166 | + progress_bar = ProgressBar( |
2167 | + widgets=['Uploading {} '.format(binary_filename), |
2168 | + Bar(marker='=', left='[', right=']'), ' ', Percentage()], |
2169 | + maxval=os.path.getsize(binary_filename)) |
2170 | + progress_bar.start() |
2171 | + # Print a newline so the progress bar has some breathing room. |
2172 | + LOGGER.info('') |
2173 | + |
2174 | + # Create a monitor for this upload, so that progress can be displayed |
2175 | + monitor = MultipartEncoderMonitor( |
2176 | + encoder, functools.partial(_update_progress_bar, progress_bar, |
2177 | + binary_file_size)) |
2178 | + |
2179 | + # Begin upload |
2180 | + response = updown_client.upload(monitor) |
2181 | + |
2182 | + # Make sure progress bar shows 100% complete |
2183 | + progress_bar.finish() |
2184 | + |
2185 | + except Exception as err: |
2186 | + raise RuntimeError( |
2187 | + 'An unexpected error was found while uploading ' |
2188 | + 'files: {!r}.'.format(err)) |
2189 | + finally: |
2190 | + # Close the open file |
2191 | + if binary_file: |
2192 | + binary_file.close() |
2193 | + |
2194 | + if not response.ok: |
2195 | + raise StoreUploadError(response) |
2196 | + |
2197 | + response_data = response.json() |
2198 | + return { |
2199 | + 'upload_id': response_data['upload_id'], |
2200 | + 'binary_filesize': binary_file_size, |
2201 | + 'source_uploaded': False, |
2202 | + } |
2203 | |
2204 | === removed directory 'snappy_ecosystem_tests/snapcraft' |
2205 | === removed file 'snappy_ecosystem_tests/snapcraft/__init__.py' |
2206 | --- snappy_ecosystem_tests/snapcraft/__init__.py 2017-02-09 20:09:40 +0000 |
2207 | +++ snappy_ecosystem_tests/snapcraft/__init__.py 1970-01-01 00:00:00 +0000 |
2208 | @@ -1,19 +0,0 @@ |
2209 | -# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
2210 | - |
2211 | -# |
2212 | -# Snappy Ecosystem Tests |
2213 | -# Copyright (C) 2017 Canonical |
2214 | -# |
2215 | -# This program is free software: you can redistribute it and/or modify |
2216 | -# it under the terms of the GNU General Public License as published by |
2217 | -# the Free Software Foundation, either version 3 of the License, or |
2218 | -# (at your option) any later version. |
2219 | -# |
2220 | -# This program is distributed in the hope that it will be useful, |
2221 | -# but WITHOUT ANY WARRANTY; without even the implied warranty of |
2222 | -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
2223 | -# GNU General Public License for more details. |
2224 | -# |
2225 | -# You should have received a copy of the GNU General Public License |
2226 | -# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2227 | -# |
2228 | |
2229 | === removed file 'snappy_ecosystem_tests/snapcraft/snapcraft.py' |
2230 | --- snappy_ecosystem_tests/snapcraft/snapcraft.py 2017-02-15 19:14:19 +0000 |
2231 | +++ snappy_ecosystem_tests/snapcraft/snapcraft.py 1970-01-01 00:00:00 +0000 |
2232 | @@ -1,74 +0,0 @@ |
2233 | -# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
2234 | - |
2235 | -# |
2236 | -# Snappy Ecosystem Tests |
2237 | -# Copyright (C) 2017 Canonical |
2238 | -# |
2239 | -# This program is free software: you can redistribute it and/or modify |
2240 | -# it under the terms of the GNU General Public License as published by |
2241 | -# the Free Software Foundation, either version 3 of the License, or |
2242 | -# (at your option) any later version. |
2243 | -# |
2244 | -# This program is distributed in the hope that it will be useful, |
2245 | -# but WITHOUT ANY WARRANTY; without even the implied warranty of |
2246 | -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
2247 | -# GNU General Public License for more details. |
2248 | -# |
2249 | -# You should have received a copy of the GNU General Public License |
2250 | -# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2251 | -# |
2252 | - |
2253 | -"""Snapcraft client helpers""" |
2254 | - |
2255 | -import pexpect |
2256 | - |
2257 | - |
2258 | -# login credentials exported by shell environment |
2259 | -from snappy_ecosystem_tests.utils import storeconfig |
2260 | - |
2261 | -LOGIN_EMAIL, LOGIN_PASSWORD = storeconfig.get_store_credentials() |
2262 | - |
2263 | - |
2264 | -class Snapcraft(object): |
2265 | - """Contain Snapcraft specific functionality to use via command |
2266 | - line interface""" |
2267 | - def __init__(self): |
2268 | - """Create new snapcraft instance.""" |
2269 | - self._login = False |
2270 | - self._cleanup() |
2271 | - |
2272 | - def _cleanup(self): |
2273 | - """Perform cleanup actions""" |
2274 | - self.logout() |
2275 | - |
2276 | - def logout(self): |
2277 | - """logout of snapcraft store session""" |
2278 | - child = pexpect.spawn("snapcraft logout") |
2279 | - err = child.expect('Credentials cleared.') |
2280 | - child.terminate(True) |
2281 | - child.wait() |
2282 | - if err is not 0: |
2283 | - raise ValueError("Failed to logout") |
2284 | - |
2285 | - self._login = False |
2286 | - |
2287 | - def login(self): |
2288 | - """login to store using the credential environment variables""" |
2289 | - child = pexpect.spawn("snapcraft login") |
2290 | - child.expect('Email: ') |
2291 | - child.sendline(LOGIN_EMAIL) |
2292 | - child.expect('Password:') |
2293 | - child.sendline(LOGIN_PASSWORD) |
2294 | - err = child.expect('Login successful') |
2295 | - if err is not 0: |
2296 | - raise ValueError("Failed to login") |
2297 | - |
2298 | - self._login = True |
2299 | - |
2300 | - def list_registered(self): |
2301 | - """call snapcraft list-registered command, |
2302 | - raise exception if not logged in""" |
2303 | - if self._login is False: |
2304 | - raise ValueError("User is not logged in, " |
2305 | - "please login before using this command") |
2306 | - return pexpect.spawnu("snapcraft list-registered").read() |
2307 | |
2308 | === added file 'snappy_ecosystem_tests/tests/test_store_apis_login.py' |
2309 | --- snappy_ecosystem_tests/tests/test_store_apis_login.py 1970-01-01 00:00:00 +0000 |
2310 | +++ snappy_ecosystem_tests/tests/test_store_apis_login.py 2017-02-20 17:48:06 +0000 |
2311 | @@ -0,0 +1,39 @@ |
2312 | +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- |
2313 | + |
2314 | +# |
2315 | +# Snappy Ecosystem Tests |
2316 | +# Copyright (C) 2017 Canonical |
2317 | +# |
2318 | +# This program is free software: you can redistribute it and/or modify |
2319 | +# it under the terms of the GNU General Public License as published by |
2320 | +# the Free Software Foundation, either version 3 of the License, or |
2321 | +# (at your option) any later version. |
2322 | +# |
2323 | +# This program is distributed in the hope that it will be useful, |
2324 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
2325 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
2326 | +# GNU General Public License for more details. |
2327 | +# |
2328 | +# You should have received a copy of the GNU General Public License |
2329 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2330 | +# |
2331 | + |
2332 | +"""Test for Login to the Store via RESTful APIs""" |
2333 | + |
2334 | +from snappy_ecosystem_tests.helpers.store_apis.rest_apis import Store |
2335 | +from snappy_ecosystem_tests.helpers.test_base import SnappyEcosystemTestCase |
2336 | +from snappy_ecosystem_tests.utils.storeconfig import get_store_credentials |
2337 | + |
2338 | + |
2339 | +class StoreAPILoginTestCase(SnappyEcosystemTestCase): |
2340 | + |
2341 | + def setUp(self): |
2342 | + super().setUp() |
2343 | + self.client = Store() |
2344 | + |
2345 | + def test_api_login_logout(self): |
2346 | + """Test login and logout functionality in store REST APIs""" |
2347 | + self.client.login(*get_store_credentials()) |
2348 | + self.client.load_config() |
2349 | + self.assertIsNotNone(self.client.config.get('macaroon')) |
2350 | + self.assertIsNotNone(self.client.config.get('unbound_discharge')) |
2351 | |
2352 | === modified file 'snappy_ecosystem_tests/tests/test_store_login.py' |
2353 | --- snappy_ecosystem_tests/tests/test_store_login.py 2017-02-15 19:14:19 +0000 |
2354 | +++ snappy_ecosystem_tests/tests/test_store_login.py 2017-02-20 17:48:06 +0000 |
2355 | @@ -20,7 +20,7 @@ |
2356 | |
2357 | """Test for Login to the Store via Web Interface, snapcraft and snapd""" |
2358 | |
2359 | -from snappy_ecosystem_tests.snapcraft.snapcraft import Snapcraft |
2360 | +from snappy_ecosystem_tests.helpers.snapcraft.client import Snapcraft |
2361 | from snappy_ecosystem_tests.helpers.web_test_base import UbuntuStoreWebTestsBase |
2362 | |
2363 |
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