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

Proposed by Heber Parrucci
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
Reviewer Review Type Date Requested Status
platform-qa-bot continuous-integration Approve
Omer Akram (community) Approve
Santiago Baldassin (community) Approve
Review via email: mp+317220@code.launchpad.net

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_ecosystem_tests/tests/test_store_apis_login.py

To post a comment you must log in.
Revision history for this message
I Ahmad (iahmad) wrote : Posted in a previous version of this proposal

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

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

Otherwise LGTM

Revision history for this message
Omer Akram (om26er) wrote : 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.

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote : Posted in a previous version of this proposal
review: Needs Fixing (continuous-integration)
Revision history for this message
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

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
7. By Heber Parrucci

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

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
8. By Heber Parrucci

fixing error in requirements.txt

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
9. By Heber Parrucci

removing duplicated dependencies in requirements.txt

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
10. By Heber Parrucci

changing dict initialization sintax according to PEP 448

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
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

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
13. By Heber Parrucci

changing dict initialization

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Needs Fixing (continuous-integration)
14. By Heber Parrucci

chaning dict initialization until jenkins slave is updated

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Omer Akram (om26er) wrote :

Can you tell how should the config file look like ? I ran the tests and got http://paste.ubuntu.com/24006501/ as the config file was probably empty.

review: Needs Information
15. By Heber Parrucci

Updating README.rst with user credentials instructions

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

> Can you tell how should the config file look like ? I ran the tests and got
> http://paste.ubuntu.com/24006501/ as the config file was probably empty.

Thanks for reviewing!
I have added a section in README that is called: 'User Credentials' here it explains the alternatives for providing them.

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Approve (continuous-integration)
16. By Heber Parrucci

merge from trunk

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Approve (continuous-integration)
17. By Heber Parrucci

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

Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Approve (continuous-integration)
18. By Heber Parrucci

renaming class Store and adding a better docstring

19. By Heber Parrucci

fixing pylint

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

Code looks good to me

review: Approve
20. By Heber Parrucci

updating license year in headers

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

code looks good. test passes. Lets land it.

review: Approve
Revision history for this message
platform-qa-bot (platform-qa-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)
21. By Heber Parrucci

merge from trunk

Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)
Revision history for this message
platform-qa-bot (platform-qa-bot) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'README.rst'
--- README.rst 2017-02-13 16:00:04 +0000
+++ README.rst 2017-02-20 17:48:06 +0000
@@ -56,6 +56,25 @@
5656
57pytest.cfg stores the config related to pytest57pytest.cfg stores the config related to pytest
5858
59User Credentials
60================
61For storing user credentials you have 2 options:
62
63option 1 - You can create a config file in your local machine with path:
64~/.config/ecosystem_tests.cfg
65You can change the dir location of that config file by setting the env variable:
66XDG_USER_CONFIG_HOME=^DIR_PATH^
67
68The config file should look like:
69[user]
70user_email=^USER_NAME^
71user_password=^USER_PASSWORD^
72
73option 2 - Set the following environment variables:
74user_email=^USER_NAME^
75user_password=^USER_PASSWORD^
76
77
59Changing store:78Changing store:
60===============79===============
61You can change the store to use in config file ecosystem_tests.cfg80You can change the store to use in config file ecosystem_tests.cfg
6281
=== modified file 'pylint.cfg'
--- pylint.cfg 2017-02-15 19:14:19 +0000
+++ pylint.cfg 2017-02-20 17:48:06 +0000
@@ -336,7 +336,7 @@
336[DESIGN]336[DESIGN]
337337
338# Maximum number of arguments for function / method338# Maximum number of arguments for function / method
339max-args=5339max-args=8
340340
341# Argument names that match this expression will be ignored. Default to name341# Argument names that match this expression will be ignored. Default to name
342# with leading underscore342# with leading underscore
343343
=== modified file 'requirements.txt'
--- requirements.txt 2017-02-13 17:43:02 +0000
+++ requirements.txt 2017-02-20 17:48:06 +0000
@@ -1,3 +1,4 @@
1libnacl
1pytest2pytest
2testtools3testtools
3unittest24unittest2
@@ -11,4 +12,6 @@
11pyxdg==0.2512pyxdg==0.25
12jsonschema==2.5.113jsonschema==2.5.1
13pexpect14pexpect
15pymacaroons==0.9.2
16requests-toolbelt==0.6.0
14chromedriver_installer17chromedriver_installer
1518
=== modified file 'snappy_ecosystem_tests/helpers/fixture_setup.py'
--- snappy_ecosystem_tests/helpers/fixture_setup.py 2017-02-15 19:14:19 +0000
+++ snappy_ecosystem_tests/helpers/fixture_setup.py 2017-02-20 17:48:06 +0000
@@ -18,10 +18,13 @@
18# along with this program. If not, see <http://www.gnu.org/licenses/>.18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#19#
2020
21"""Manage general tests fixtures"""21
22"""General fixtures related to the store and the tests environment"""
2223
23import os24import os
25
24import fixtures26import fixtures
27
25from snappy_ecosystem_tests.commons.config import CONFIG_STACK28from snappy_ecosystem_tests.commons.config import CONFIG_STACK
26from snappy_ecosystem_tests.utils.storeconfig import get_current_store29from snappy_ecosystem_tests.utils.storeconfig import get_current_store
2730
@@ -30,10 +33,8 @@
30 """Create a temporary directory an cd into it for the test duration."""33 """Create a temporary directory an cd into it for the test duration."""
3134
32 def setUp(self):35 def setUp(self):
33 """Create a temporary directory an cd into it for the test duration."""
34 super().setUp()36 super().setUp()
35 current_dir = os.getcwd()37 self.addCleanup(os.chdir, os.getcwd())
36 self.addCleanup(os.chdir, current_dir)
37 os.chdir(self.path)38 os.chdir(self.path)
3839
3940
@@ -43,10 +44,8 @@
4344
44 def setUp(self):45 def setUp(self):
45 super().setUp()46 super().setUp()
46
47 current_environment = os.environ.copy()47 current_environment = os.environ.copy()
48 os.environ = {}48 os.environ = {}
49
50 self.addCleanup(os.environ.update, current_environment)49 self.addCleanup(os.environ.update, current_environment)
5150
5251
5352
=== added directory 'snappy_ecosystem_tests/helpers/snapcraft'
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/__init__.py'
--- snappy_ecosystem_tests/helpers/snapcraft/__init__.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/__init__.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,19 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
020
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/client.py'
--- snappy_ecosystem_tests/helpers/snapcraft/client.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/client.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,74 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Snapcraft client helpers"""
22
23import pexpect
24
25
26# login credentials exported by shell environment
27from snappy_ecosystem_tests.utils import storeconfig
28
29LOGIN_EMAIL, LOGIN_PASSWORD = storeconfig.get_store_credentials()
30
31
32class Snapcraft(object):
33 """Contain Snapcraft specific functionality to use via command
34 line interface"""
35 def __init__(self):
36 """Create new snapcraft instance."""
37 self._login = False
38 self._cleanup()
39
40 def _cleanup(self):
41 """Perform cleanup actions"""
42 self.logout()
43
44 def logout(self):
45 """logout of snapcraft store session"""
46 child = pexpect.spawn("snapcraft logout")
47 err = child.expect('Credentials cleared.')
48 child.terminate(True)
49 child.wait()
50 if err is not 0:
51 raise ValueError("Failed to logout")
52
53 self._login = False
54
55 def login(self):
56 """login to store using the credential environment variables"""
57 child = pexpect.spawn("snapcraft login")
58 child.expect('Email: ')
59 child.sendline(LOGIN_EMAIL)
60 child.expect('Password:')
61 child.sendline(LOGIN_PASSWORD)
62 err = child.expect('Login successful')
63 if err is not 0:
64 raise ValueError("Failed to login")
65
66 self._login = True
67
68 def list_registered(self):
69 """call snapcraft list-registered command,
70 raise exception if not logged in"""
71 if self._login is False:
72 raise ValueError("User is not logged in, "
73 "please login before using this command")
74 return pexpect.spawnu("snapcraft list-registered").read()
075
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/config.py'
--- snappy_ecosystem_tests/helpers/snapcraft/config.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/config.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,114 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Helpers to store/manage/load snapcraft configuration in a config file"""
22
23import configparser
24import logging
25import os
26import urllib.parse
27
28from snappy_ecosystem_tests.commons.config import CONFIG_STACK
29from xdg import BaseDirectory
30
31LOCAL_CONFIG_FILENAME = '.snapcraft/snapcraft.cfg'
32
33LOGGER = logging.getLogger(__name__)
34
35
36class SnapcraftConfig(object):
37 """Hold configuration options in sections.
38 There can be two sections for the sso related credentials: production and
39 staging. This is governed by the UBUNTU_SSO_API_ROOT_URL environment
40 variable. Other sections are ignored but preserved.
41 """
42
43 def __init__(self):
44 self.parser = configparser.ConfigParser()
45 self.filename = None
46 self.load()
47
48 @staticmethod
49 def _section_name():
50 """Return a section"""
51 # The only section we care about is the host from the SSO url
52 url = os.environ.get('UBUNTU_SSO_API_ROOT_URL',
53 CONFIG_STACK.config.get('production_urls', 'sso'))
54 return urllib.parse.urlparse(url).netloc
55
56 def get(self, option_name):
57 """Get an option name for a given section"""
58 try:
59 return self.parser.get(self._section_name(), option_name)
60 except (configparser.NoSectionError,
61 configparser.NoOptionError,
62 KeyError):
63 return None
64
65 def set(self, option_name, value):
66 """Set an option with the given value"""
67 section_name = self._section_name()
68 if not self.parser.has_section(section_name):
69 self.parser.add_section(section_name)
70 return self.parser.set(section_name, option_name, value)
71
72 def is_empty(self):
73 """Return True if snapcraft config does not have the default section
74 name"""
75 # Only check the current section
76 section_name = self._section_name()
77 if self.parser.has_section(section_name):
78 if self.parser.options(section_name):
79 return False
80 return True
81
82 def load(self):
83 """Load the snapcraft configuration"""
84 # Local configurations (per project) are supposed to be static.
85 # That's why it's only checked for 'loading' and never written to.
86 # Essentially, all authentication-related changes, like login/logout
87 # or macaroon-refresh, will not be persisted for the next runs.
88 if os.path.exists(LOCAL_CONFIG_FILENAME):
89 self.parser.read(LOCAL_CONFIG_FILENAME)
90 LOGGER.warning(
91 'Using local configuration (`%s`), changes will '
92 'not be persisted.', LOCAL_CONFIG_FILENAME)
93 return
94
95 self.filename = BaseDirectory.load_first_config(
96 'snapcraft', 'snapcraft.cfg')
97 if self.filename and os.path.exists(self.filename):
98 self.parser.read(self.filename)
99
100 @staticmethod
101 def save_path():
102 """Return the save path for the snapcraft configuration"""
103 return os.path.join(BaseDirectory.save_config_path('snapcraft'),
104 'snapcraft.cfg')
105
106 def save(self):
107 """Save the current configuration in the save path"""
108 self.filename = self.save_path()
109 with open(self.filename, 'w') as _f:
110 self.parser.write(_f)
111
112 def clear(self):
113 """Clear the current configuration"""
114 self.parser.remove_section(self._section_name())
0115
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/constants.py'
--- snappy_ecosystem_tests/helpers/snapcraft/constants.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/constants.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,52 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Store snapcraft constants"""
22
23from __future__ import absolute_import, unicode_literals
24
25
26DEFAULT_SERIES = '16'
27SCAN_STATUS_POLL_DELAY = 5
28SCAN_STATUS_POLL_RETRIES = 5
29
30# Messages and warnings.
31MISSING_AGREEMENT = 'Developer has not signed agreement.'
32MISSING_NAMESPACE = 'Developer profile is missing short namespace.'
33AGREEMENT_ERROR = (
34 'You must agree to the developer terms and conditions to upload snaps.')
35NAMESPACE_ERROR = (
36 'You need to set a username. It will appear in the developer field '
37 'alongside the other details for your snap. Please visit {} and login '
38 'again.')
39AGREEMENT_INPUT_MSG = (
40 'Do you agree to the developer terms and conditions. ({})? [y/N] ')
41AGREEMENT_SIGN_ERROR = (
42 'Unexpected error encountered during signing the developer terms and '
43 'conditions. Please visit {} and agree to the terms and conditions before '
44 'continuing.')
45TWO_FACTOR_WARNING = (
46 'We strongly recommend enabling multi-factor authentication: '
47 'https://help.ubuntu.com/community/SSO/FAQs/2FA')
48INVALID_CREDENTIALS = 'Invalid credentials supplied.'
49AUTHENTICATION_ERROR = ('Problems encountered when authenticating your '
50 'credentials.')
51ACCOUNT_INFORMATION_ERROR = ('Unexpected error when obtaining your account '
52 'information.')
053
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/deprecations.py'
--- snappy_ecosystem_tests/helpers/snapcraft/deprecations.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/deprecations.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,56 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Handle surfacing deprecation notices.
22
23When a new deprecation has occurred, write a Deprecation Notice for it on the
24wiki, assigning it the next ID (DN1, DN2, etc.). Then add that ID along with a
25brief message for surfacing to the user into the _DEPRECATION_MESSAGES in this
26module.
27"""
28
29import logging
30
31_DEPRECATION_MESSAGES = {
32 'dn1': "The 'snap' keyword has been replaced by 'prime'.",
33 'dn2': "Custom plugins should now be placed in 'snap/plugins'.",
34 'dn3': "Assets in 'setup/gui' should now be placed in 'snap/gui'.",
35}
36
37_DEPRECATION_URL_FMT = 'http://snapcraft.io/docs/deprecation-notices/{id}'
38
39LOGGER = logging.getLogger(__name__)
40
41
42def _deprecation_message(_id):
43 """Return the deprecation message for the given id
44 :raise RuntimeError
45 """
46 message = _DEPRECATION_MESSAGES.get(_id)
47 if not message:
48 raise RuntimeError('No deprecation notice with id {!r}'.format(id))
49 return message
50
51
52def handle_deprecation_notice(_id):
53 """Handle deprecation notice by logging a message"""
54 message = _deprecation_message(_id)
55 LOGGER.warning('DEPRECATED: %s\nSee %s for more information.',
56 message, _DEPRECATION_URL_FMT.format(id=_id))
057
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/indicators.py'
--- snappy_ecosystem_tests/helpers/snapcraft/indicators.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/indicators.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,118 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Helpers for snapcraft indicators with pretty progress bar"""
22
23import os
24import sys
25
26from urllib.request import urlretrieve
27from progressbar import (
28 AnimatedMarker,
29 Bar,
30 Percentage,
31 ProgressBar,
32 UnknownLength,
33)
34
35
36def _init_progress_bar(total_length, destination, message=None):
37 """Init the progress bar with the given length, destination and message"""
38 if not message:
39 message = 'Downloading {!r}'.format(os.path.basename(destination))
40
41 valid_length = total_length and total_length > 0
42
43 if valid_length and is_dumb_terminal():
44 widgets = [message, ' ', Percentage()]
45 maxval = total_length
46 elif valid_length and not is_dumb_terminal():
47 widgets = [message,
48 Bar(marker='=', left='[', right=']'),
49 ' ', Percentage()]
50 maxval = total_length
51 elif not valid_length and is_dumb_terminal():
52 widgets = [message]
53 maxval = UnknownLength
54 else:
55 widgets = [message, AnimatedMarker()]
56 maxval = UnknownLength
57
58 return ProgressBar(widgets=widgets, maxval=maxval)
59
60
61def download_requests_stream(request_stream, destination, message=None):
62 """This is a facility to download a request with nice progress bars."""
63
64 # Doing len(request_stream.content) may defeat the purpose of a
65 # progress bar
66 total_length = 0
67 if not request_stream.headers.get('Content-Encoding', ''):
68 total_length = int(request_stream.headers.get('Content-Length', '0'))
69
70 total_read = 0
71 progress_bar = _init_progress_bar(total_length, destination, message)
72 progress_bar.start()
73 with open(destination, 'wb') as destination_file:
74 for buf in request_stream.iter_content(1024):
75 destination_file.write(buf)
76 total_read += len(buf)
77 progress_bar.update(total_read)
78 progress_bar.finish()
79
80
81class UrllibDownloader(object):
82 """This is a facility to download an uri with nice progress bars."""
83
84 def __init__(self, uri, destination, message=None):
85 self.uri = uri
86 self.destination = destination
87 self.message = message
88 self.progress_bar = None
89
90 def download(self):
91 """Download with a pretty progress bar"""
92 urlretrieve(self.uri, self.destination, self._progress_callback)
93
94 if self.progress_bar:
95 self.progress_bar.finish()
96
97 def _progress_callback(self, block_num, block_size, total_length):
98 """Callback to be used when downloading"""
99 if not self.progress_bar:
100 self.progress_bar = _init_progress_bar(
101 total_length, self.destination, self.message)
102 self.progress_bar.start()
103
104 total_read = block_num * block_size
105 self.progress_bar.update(
106 min(total_read, total_length) if total_length > 0 else total_read)
107
108
109def download_urllib_source(uri, destination, message=None):
110 """Perform a download from the given uri to the given destination"""
111 UrllibDownloader(uri, destination, message).download()
112
113
114def is_dumb_terminal():
115 """Return True if on a dumb terminal."""
116 is_stdout_tty = os.isatty(sys.stdout.fileno())
117 is_term_dumb = os.environ.get('TERM', '') == 'dumb'
118 return not is_stdout_tty or is_term_dumb
0119
=== added file 'snappy_ecosystem_tests/helpers/snapcraft/options.py'
--- snappy_ecosystem_tests/helpers/snapcraft/options.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/snapcraft/options.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,244 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Manage snapcraft project options"""
22
23import logging
24import multiprocessing
25import os
26import platform
27
28from snappy_ecosystem_tests.helpers.snapcraft.deprecations import (
29 handle_deprecation_notice
30)
31
32LOGGER = logging.getLogger(__name__)
33
34
35_ARCH_TRANSLATIONS = {
36 'armv7l': {
37 'kernel': 'arm',
38 'deb': 'armhf',
39 'cross-compiler-prefix': 'arm-linux-gnueabihf-',
40 'cross-build-packages': ['gcc-arm-linux-gnueabihf'],
41 'triplet': 'arm-linux-gnueabihf',
42 'core-dynamic-linker': 'lib/ld-linux-armhf.so.3',
43 },
44 'aarch64': {
45 'kernel': 'arm64',
46 'deb': 'arm64',
47 'cross-compiler-prefix': 'aarch64-linux-gnu-',
48 'cross-build-packages': ['gcc-aarch64-linux-gnu'],
49 'triplet': 'aarch64-linux-gnu',
50 'core-dynamic-linker': 'lib/ld-linux-aarch64.so.1',
51 },
52 'i686': {
53 'kernel': 'x86',
54 'deb': 'i386',
55 'triplet': 'i386-linux-gnu',
56 },
57 'ppc64le': {
58 'kernel': 'powerpc',
59 'deb': 'ppc64el',
60 'cross-compiler-prefix': 'powerpc64le-linux-gnu-',
61 'cross-build-packages': ['gcc-powerpc64le-linux-gnu'],
62 'triplet': 'powerpc64le-linux-gnu',
63 'core-dynamic-linker': '/lib64/ld64.so.2',
64 },
65 'ppc': {
66 'kernel': 'powerpc',
67 'deb': 'powerpc',
68 'cross-compiler-prefix': 'powerpc-linux-gnu-',
69 'cross-build-packages': ['gcc-powerpc-linux-gnu'],
70 'triplet': 'powerpc-linux-gnu',
71 },
72 'x86_64': {
73 'kernel': 'x86',
74 'deb': 'amd64',
75 'triplet': 'x86_64-linux-gnu',
76 'core-dynamic-linker': 'lib64/ld-linux-x86-64.so.2',
77 },
78 's390x': {
79 'kernel': 's390x',
80 'deb': 's390x',
81 'cross-compiler-prefix': 's390x-linux-gnu-',
82 'cross-build-packages': ['gcc-s390x-linux-gnu'],
83 'triplet': 's390x-linux-gnu',
84 'core-dynamic-linker': '/lib/ld64.so.1',
85 }
86}
87
88
89_32BIT_USERSPACE_ARCHITECTURE = {
90 'aarch64': 'armv7l',
91 'armv8l': 'armv7l',
92 'ppc64le': 'ppc',
93 'x86_64': 'i686',
94}
95
96
97def _get_platform_architecture():
98 """Return the current platform architecture of the machine"""
99 architecture = platform.machine()
100 if platform.architecture()[0] == '32bit':
101 userspace = _32BIT_USERSPACE_ARCHITECTURE.get(architecture)
102 if userspace:
103 architecture = userspace
104 return architecture
105
106
107class ProjectOptions:
108 """Represents a snapcraft project options"""
109 def __init__(self, use_geoip=False, parallel_builds=True,
110 target_deb_arch=None, debug=False):
111 self.__project_dir = os.getcwd()
112 self.__use_geoip = use_geoip
113 self.__parallel_builds = parallel_builds
114 self._set_machine(target_deb_arch)
115 self.__debug = debug
116
117 @property
118 def use_geoip(self):
119 """Return True if the project uses geoip"""
120 return self.__use_geoip
121
122 @property
123 def parallel_builds(self):
124 """Return True if the project uses parallel build"""
125 return self.__parallel_builds
126
127 @property
128 def parallel_build_count(self):
129 """Return the amount of cpus used for parallel builds"""
130 build_count = 1
131 if self.__parallel_builds:
132 try:
133 build_count = multiprocessing.cpu_count()
134 except NotImplementedError:
135 LOGGER.warning(
136 'Unable to determine CPU count; disabling parallel builds')
137 return build_count
138
139 @property
140 def is_cross_compiling(self):
141 """Return True if the target machine does not match
142 the platform arch of the project"""
143 return self.__target_machine != self.__platform_arch
144
145 @property
146 def cross_compiler_prefix(self):
147 """Return the cross compiler prefix"""
148 try:
149 return self.__machine_info['cross-compiler-prefix']
150 except KeyError:
151 raise EnvironmentError(
152 'Cross compilation not support for target arch {}'.format(
153 self.__target_machine))
154
155 @property
156 def additional_build_packages(self):
157 """Return the additional build packages from the machine info"""
158 packages = []
159 if self.is_cross_compiling:
160 packages.extend(self.__machine_info.get(
161 'cross-build-packages', []))
162 return packages
163
164 @property
165 def arch_triplet(self):
166 """Return the arch triplet from the machine info"""
167 return self.__machine_info['triplet']
168
169 @property
170 def deb_arch(self):
171 """Return the deb arch from the machine info"""
172 return self.__machine_info['deb']
173
174 @property
175 def kernel_arch(self):
176 """Return the kernel arch from the machine info"""
177 return self.__machine_info['kernel']
178
179 @property
180 def local_plugins_dir(self):
181 """Return the plugin directory"""
182 deprecated_plugins_dir = os.path.join(self.parts_dir, 'plugins')
183 if os.path.exists(deprecated_plugins_dir):
184 handle_deprecation_notice('dn2')
185 return deprecated_plugins_dir
186 return os.path.join(self.__project_dir, 'snap', 'plugins')
187
188 @property
189 def parts_dir(self):
190 """Return the parts directory"""
191 return os.path.join(self.__project_dir, 'parts')
192
193 @property
194 def stage_dir(self):
195 """Return the stage directory"""
196 return os.path.join(self.__project_dir, 'stage')
197
198 @property
199 def snap_dir(self):
200 """Return the snap directory"""
201 return os.path.join(self.__project_dir, 'prime')
202
203 @property
204 def debug(self):
205 """Return debug of the project options"""
206 return self.__debug
207
208 def get_core_dynamic_linker(self):
209 """Returns the dynamic linker used for the targetted core.
210 If not found realpath for `/lib/ld-linux.so.2` is returned.
211 However if core is not installed None will be returned.
212 """
213 core_path = os.path.join('/snap', 'core', 'current')
214 core_dynamic_linker = self.__machine_info.get('core-dynamic-linker',
215 'lib/ld-linux.so.2')
216
217 try:
218 dynamic_linker_resolved_path = os.readlink(
219 os.path.join(core_path, core_dynamic_linker))
220 dynamic_linker_path = os.path.join(
221 core_path, dynamic_linker_resolved_path.lstrip('/'))
222 except FileNotFoundError:
223 dynamic_linker_path = None
224
225 return dynamic_linker_path
226
227 def _set_machine(self, target_deb_arch):
228 """Set the target machine with the given deb arch"""
229 self.__platform_arch = _get_platform_architecture()
230 if not target_deb_arch:
231 self.__target_machine = self.__platform_arch
232 else:
233 self.__target_machine = _find_machine(target_deb_arch)
234 LOGGER.info('Setting target machine to {%s}', target_deb_arch)
235 self.__machine_info = _ARCH_TRANSLATIONS[self.__target_machine]
236
237
238def _find_machine(deb_arch):
239 """Return a machine for the given deb arch"""
240 for machine in _ARCH_TRANSLATIONS:
241 if _ARCH_TRANSLATIONS[machine].get('deb', '') == deb_arch:
242 return machine
243 raise EnvironmentError(
244 'Cannot set machine from deb_arch {!r}'.format(deb_arch))
0245
=== renamed directory 'snappy_ecosystem_tests/snapd' => 'snappy_ecosystem_tests/helpers/snapd'
=== added directory 'snappy_ecosystem_tests/helpers/store_apis'
=== added file 'snappy_ecosystem_tests/helpers/store_apis/__init__.py'
--- snappy_ecosystem_tests/helpers/store_apis/__init__.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/store_apis/__init__.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,19 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
020
=== added file 'snappy_ecosystem_tests/helpers/store_apis/errors.py'
--- snappy_ecosystem_tests/helpers/store_apis/errors.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/store_apis/errors.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,371 @@
1# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
2#
3# Copyright (C) 2017 Canonical Ltd
4#
5# This program is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""All the store exceptions"""
18
19from simplejson.scanner import JSONDecodeError
20
21
22class StoreException(Exception):
23 """Base class for all store exceptions.
24 :cvar fmt: A format string that daughter classes override
25 """
26 fmt = 'Daughter classes should redefine this'
27
28 def __init__(self, **kwargs):
29 super().__init__()
30 for key, value in kwargs.items():
31 setattr(self, key, value)
32
33 def __str__(self):
34 return self.fmt.format([], **self.__dict__)
35
36
37class InvalidCredentialsError(StoreException):
38 """Exception to be raised when credentials are invalid"""
39 fmt = 'Invalid credentials: {message}.'
40
41 def __init__(self, message):
42 super().__init__(message=message)
43
44
45class SnapNotFoundError(StoreException):
46 """Exception to be raised when a snap is not found in the store"""
47 __FMT_ARCH_CHANNEL = (
48 'Snap {name!r} for {arch!r} cannot be found in the {channel!r} '
49 'channel.')
50 __FMT_CHANNEL = 'Snap {name!r} was not found in the {channel!r} channel.'
51 __FMT_SERIES_ARCH = (
52 'Snap {name!r} for {arch!r} was not found in {series!r} series.')
53 __FMT_SERIES = 'Snap {name!r} was not found in {series!r} series.'
54
55 fmt = 'Snap {name!r} was not found.'
56
57 def __init__(self, name, channel=None, arch=None, series=None):
58 if channel and arch:
59 self.fmt = self.__FMT_ARCH_CHANNEL
60 elif channel:
61 self.fmt = self.__FMT_CHANNEL
62 elif series and arch:
63 self.fmt = self.__FMT_SERIES_ARCH
64 elif series:
65 self.fmt = self.__FMT_SERIES
66
67 super().__init__(name=name, channel=channel, arch=arch, series=series)
68
69
70class SHAMismatchError(StoreException):
71 """Exception to be raised when there is a mismatch in sha512"""
72 fmt = 'SHA512 checksum for {path} is not {expected_sha}.'
73
74 def __init__(self, path, expected_sha):
75 super().__init__(path=path, expected_sha=expected_sha)
76
77
78class StoreAuthenticationError(StoreException):
79 """Exception to be raised when there is an authentication error in the
80 store"""
81 fmt = 'Authentication error: {}.'
82
83 def __init__(self, message):
84 super().__init__(message=message)
85
86
87class StoreTwoFactorAuthenticationRequired(StoreAuthenticationError):
88 """Exception to be raised when the credentials were provided but 2-factor
89 authentication was not and is required.
90 """
91 def __init__(self):
92 super().__init__("Two-factor authentication required.")
93
94
95class StoreMacaroonNeedsRefreshError(StoreException):
96 """Exception to be raised when a request needs to refresh
97 the macaroon to be authenticated properly"""
98 fmt = 'Authentication macaroon needs to be refreshed.'
99
100
101class DeveloperAgreementSignError(StoreException):
102 """Exception to be raised when developer tries to do an operation
103 in the store but he did not sign the Agreement yet"""
104 fmt = (
105 'There was an error while signing developer agreement.\n'
106 'Reason: {reason!r}\n'
107 'Text: {text!r}')
108
109 def __init__(self, response):
110 super().__init__(reason=response.reason, text=response.text)
111
112
113class NeedTermsSignedError(StoreException):
114 """Exception to be raised when an operation needs the Developer
115 Terms of Service to be signed before proceed"""
116 fmt = (
117 'Developer Terms of Service agreement must be signed '
118 'before continuing: {message}')
119
120 def __init__(self, message):
121 super().__init__(message=message)
122
123
124class StoreAccountInformationError(StoreException):
125 """Exception to be raised when there was not possible
126 to fetch the user account information"""
127 fmt = 'Error fetching account information from store: {error}'
128
129 def __init__(self, response):
130 error = '{} {}'.format(response.status_code, response.reason)
131 extra = []
132 try:
133 response_json = response.json()
134 if 'error_list' in response_json:
135 error = ' '.join(
136 error['message'] for error in response_json['error_list'])
137 extra = [
138 error['extra'] for error in response_json[
139 'error_list'] if 'extra' in error]
140 except JSONDecodeError:
141 pass
142 super().__init__(error=error, extra=extra)
143
144
145class StoreKeyRegistrationError(StoreException):
146 """Exception to be raised when there was a problem
147 registering a key in the store"""
148 fmt = 'Key registration failed: {error}'
149
150 def __init__(self, response):
151 error = '{} {}'.format(response.status_code, response.reason)
152 try:
153 response_json = response.json()
154 if 'error_list' in response_json:
155 error = ' '.join(
156 error['message'] for error in response_json['error_list'])
157 except JSONDecodeError:
158 pass
159 super().__init__(error=error)
160
161
162class StoreRegistrationError(StoreException):
163 """Exception to be raised when there was a problem registering
164 a snap name"""
165 __FMT_ALREADY_REGISTERED = (
166 'The name {snap_name!r} is already taken.\n\n'
167 'We can if needed rename snaps to ensure they match the expectations '
168 'of most users. If you are the publisher most users expect for '
169 '{snap_name!r} then claim the name at {register_name_url!r}')
170
171 __FMT_ALREADY_OWNED = 'You already own the name {snap_name!r}.'
172
173 __FMT_RESERVED = (
174 'The name {snap_name!r} is reserved.\n\n'
175 'If you are the publisher most users expect for '
176 '{snap_name!r} then please claim the name at {register_name_url!r}')
177
178 __FMT_RETRY_WAIT = (
179 'You must wait {retry_after} seconds before trying to register '
180 'your next snap.')
181
182 fmt = 'Registration failed.'
183
184 __error_messages = {
185 'already_registered': __FMT_ALREADY_REGISTERED,
186 'already_owned': __FMT_ALREADY_OWNED,
187 'reserved_name': __FMT_RESERVED,
188 'register_window': __FMT_RETRY_WAIT,
189 }
190
191 def __init__(self, snap_name, response):
192 try:
193 response_json = response.json()
194 except JSONDecodeError:
195 response_json = {}
196
197 error_code = response_json.get('code')
198 if error_code:
199 # we default to self.fmt in case error_code is not mapped yet.
200 self.fmt = self.__error_messages.get(error_code, self.fmt)
201
202 super().__init__(snap_name=snap_name, **response_json)
203
204
205class StoreUploadError(StoreException):
206 """Exception to be raised when there was a problem
207 uploading a snap to the store"""
208 fmt = (
209 'There was an error uploading the package.\n'
210 'Reason: {reason!r}\n'
211 'Text: {text!r}')
212
213 def __init__(self, response):
214 super().__init__(reason=response.reason, text=response.text)
215
216
217class StorePushError(StoreException):
218 """Exception to be raised when there was a problem
219 pushing a snap to the store"""
220 __FMT_NOT_REGISTERED = (
221 'You are not the publisher or allowed to push revisions for this '
222 'snap. To become the publisher, run `snapcraft register {snap_name}` '
223 'and try to push again.')
224
225 fmt = 'Received {status_code!r}: {text!r}'
226
227 def __init__(self, snap_name, response):
228 try:
229 response_json = response.json()
230 except (AttributeError, JSONDecodeError):
231 response_json = {}
232
233 if response.status_code == 404:
234 self.fmt = self.__FMT_NOT_REGISTERED
235 elif response.status_code == 401 or response.status_code == 403:
236 try:
237 response_json['text'] = response.text
238 except AttributeError:
239 response_json['text'] = 'error while pushing'
240
241 super().__init__(snap_name=snap_name, status_code=response.status_code,
242 **response_json)
243
244
245class StoreReviewError(StoreException):
246 """Exception to be raised when there was a problem in the snap
247 that prevents it to be published/updated"""
248 __FMT_NEED_MANUAL_REVIEW = (
249 'Publishing checks failed.\n'
250 'To release this to stable channel please request a review on '
251 'the snapcraft list.\n'
252 'Use devmode in the edge or beta channels to disable confinement.')
253
254 __FMT_PROCESSING_ERROR = (
255 'There has been a problem while analyzing the snap, check the snap '
256 'and try to push again.')
257
258 __messages = {
259 'need_manual_review': __FMT_NEED_MANUAL_REVIEW,
260 'processing_error': __FMT_PROCESSING_ERROR,
261 }
262
263 def __init__(self, result):
264 self.fmt = self.__messages[result['code']]
265 super().__init__()
266
267
268class StoreReleaseError(StoreException):
269 """Exception to be raised when the snap could not be released"""
270 __FMT_NOT_REGISTERED = (
271 'Sorry, try `snapcraft register {snap_name}` before trying to '
272 'release or choose an existing revision.')
273
274 fmt = 'Received {status_code!r}: {text!r}'
275
276 def __init__(self, snap_name, response):
277 try:
278 response_json = response.json()
279 except (AttributeError, JSONDecodeError):
280 response_json = {}
281
282 if response.status_code == 404:
283 self.fmt = self.__FMT_NOT_REGISTERED
284 elif response.status_code == 401 or response.status_code == 403:
285 try:
286 response_json['text'] = response.text
287 except AttributeError:
288 response_json['text'] = 'error while releasing'
289 elif 'errors' in response_json:
290 self.fmt = '{errors}'
291
292 super().__init__(snap_name=snap_name, status_code=response.status_code,
293 **response_json)
294
295
296class StoreValidationError(StoreException):
297 """Exception to be raised when there was a validation
298 problem in the store"""
299 fmt = 'Received error {status_code!r}: {text!r}'
300
301 def __init__(self, response, message=None):
302 try:
303 response_json = response.json()
304 response_json['text'] = response.json()['error_list'][0]['message']
305 except (AttributeError, JSONDecodeError):
306 response_json = {'text': message or response}
307
308 super().__init__(status_code=response.status_code,
309 **response_json)
310
311
312class StoreSnapBuildError(StoreException):
313 """Exception to be raised when a snap build assertion fails"""
314 fmt = 'Could not assert build: {error}'
315
316 def __init__(self, response):
317 error = '{} {}'.format(response.status_code, response.reason)
318 try:
319 response_json = response.json()
320 if 'error_list' in response_json:
321 error = ' '.join(
322 error['message'] for error in response_json['error_list'])
323 except JSONDecodeError:
324 pass
325
326 super().__init__(error=error)
327
328
329class StoreSnapHistoryError(StoreException):
330 """Exception to be raised when there was a problem fetching a snap
331 history"""
332 fmt = (
333 'Error fetching history of snap id {snap_id!r} for {arch!r} '
334 'in {series!r} series: {error}.')
335
336 def __init__(self, response, snap_id, series, arch):
337 error = '{} {}'.format(response.status_code, response.reason)
338 try:
339 response_json = response.json()
340 if 'error_list' in response_json:
341 error = ' '.join(
342 error['message'] for error in response_json['error_list'])
343 except JSONDecodeError:
344 pass
345
346 super().__init__(
347 snap_id=snap_id, arch=arch or 'any arch',
348 series=series or 'any', error=error)
349
350
351class StoreSnapStatusError(StoreSnapHistoryError):
352 """Exception to be raised when there was a problem fetching a snap
353 status"""
354 fmt = (
355 'Error fetching status of snap id {snap_id!r} for {arch!r} '
356 'in {series!r} series: {error}.')
357
358
359class StoreChannelClosingError(StoreException):
360 """Exception to be raised when there was a problem fetching a snap
361 history"""
362 fmt = 'Could not close channel: {error}'
363
364 def __init__(self, response):
365 try:
366 _e = response.json()['error_list'][0]
367 error = '{}'.format(_e['message'])
368 except (JSONDecodeError, KeyError, IndexError):
369 error = '{} {}'.format(
370 response.status_code, response.reason)
371 super().__init__(error=error)
0372
=== added file 'snappy_ecosystem_tests/helpers/store_apis/rest_apis.py'
--- snappy_ecosystem_tests/helpers/store_apis/rest_apis.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/store_apis/rest_apis.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,880 @@
1# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
2#
3# Copyright (C) 2017 Canonical Ltd
4#
5# This program is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Clients needed to interact with the Snap Store"""
18
19import contextlib
20import hashlib
21import itertools
22import json
23import logging
24import os
25import urllib.parse
26from queue import Queue
27from threading import Thread
28from time import sleep
29
30import pymacaroons
31import requests
32from progressbar import (
33 AnimatedMarker,
34 ProgressBar,
35 UnknownLength,
36)
37from simplejson.scanner import JSONDecodeError
38from snappy_ecosystem_tests.commons.config import CONFIG_STACK
39
40from snappy_ecosystem_tests.helpers.snapcraft import constants
41from snappy_ecosystem_tests.helpers.snapcraft.config import SnapcraftConfig
42from snappy_ecosystem_tests.helpers.snapcraft.indicators import (
43 download_requests_stream
44)
45from snappy_ecosystem_tests.helpers.snapcraft.options import ProjectOptions
46from snappy_ecosystem_tests.helpers.store_apis import errors
47from snappy_ecosystem_tests.helpers.store_apis.upload import upload_files
48
49LOGGER = logging.getLogger(__name__)
50
51JSON_CONTENT_TYPE = {'Content-Type': 'application/json'}
52JSON_ACCEPT = {'Accept': 'application/json'}
53JSON_HEADERS = dict(JSON_CONTENT_TYPE, **JSON_ACCEPT)
54
55
56def _macaroon_auth(conf):
57 """Format a macaroon and its associated discharge.
58 :return: A string suitable to use in an Authorization header.
59 """
60 root_macaroon_raw = conf.get('macaroon')
61 if root_macaroon_raw is None:
62 raise errors.InvalidCredentialsError(
63 'Root macaroon not in the config file')
64 unbound_raw = conf.get('unbound_discharge')
65 if unbound_raw is None:
66 raise errors.InvalidCredentialsError(
67 'Unbound discharge not in the config file')
68
69 root_macaroon = _deserialize_macaroon(root_macaroon_raw)
70 unbound = _deserialize_macaroon(unbound_raw)
71 bound = root_macaroon.prepare_for_request(unbound)
72 discharge_macaroon_raw = bound.serialize()
73 auth = 'Macaroon root={}, discharge={}'.format(
74 root_macaroon_raw, discharge_macaroon_raw)
75 return auth
76
77
78def _deserialize_macaroon(value):
79 """Deserialize the given macaroon and return it
80 :raise InvalidCredentialsError: when macaroon cannot be deserialized
81 """
82 try:
83 return pymacaroons.Macaroon.deserialize(value)
84 except:
85 raise errors.InvalidCredentialsError('Failed to deserialize macaroon')
86
87
88class Client:
89 """A base class to define clients for the ols servers.
90 This is a simple wrapper around requests.Session so we inherit all good
91 bits while providing a simple point for tests to override when needed.
92 """
93
94 def __init__(self, conf, root_url):
95 self.conf = conf
96 self.root_url = root_url
97 self.session = requests.Session()
98
99 def request(self, method, url, params=None, headers=None, **kwargs):
100 """Overriding base class to handle the root url."""
101 # Note that url may be absolute in which case 'root_url' is ignored by
102 # urljoin.
103 if headers is None:
104 headers = JSON_HEADERS
105 final_url = urllib.parse.urljoin(self.root_url, url)
106 response = self.session.request(
107 method, final_url, headers=headers,
108 params=params, **kwargs)
109 return response
110
111 def get(self, url, **kwargs):
112 """Do a GET request and return the response"""
113 return self.request('GET', url, **kwargs)
114
115 def post(self, url, **kwargs):
116 """Do a POST request and return the response"""
117 return self.request('POST', url, **kwargs)
118
119 def put(self, url, **kwargs):
120 """Do a PUT request and return the response"""
121 return self.request('PUT', url, **kwargs)
122
123
124class Store:
125 """It is an entry point for all the REST operations in the store.
126 It will call the corresponding client to resolve the request.
127 It also takes care about refreshing the macaroon if necessary
128 in each request.
129 """
130
131 ACLs = ('package_upload', 'package_access')
132
133 def __init__(self):
134 self.conf = SnapcraftConfig()
135 self.sso = SSOClient(self.conf)
136 self.cpi = SnapIndexClient(self.conf)
137 self.updown = UpDownClient(self.conf)
138 self.sca = SCAClient(self.conf)
139
140 def login(self, email, password, one_time_password=None, acls=ACLs,
141 packages=None, channels=None, save=True):
142 """Log in via the Ubuntu One SSO API."""
143 # Ask the store for the needed capabilities to be associated with the
144 # macaroon.
145 macaroon = self.sca.get_macaroon(acls, packages, channels)
146 caveat_id = self._extract_caveat_id(macaroon)
147 unbound_discharge = self.sso.get_unbound_discharge(
148 email, password, one_time_password, caveat_id)
149 # The macaroon has been discharged, save it in the config
150 self.conf.set('macaroon', macaroon)
151 self.conf.set('unbound_discharge', unbound_discharge)
152 if save:
153 self.conf.save()
154
155 @property
156 def config(self):
157 """Get current config"""
158 return self.conf
159
160 def load_config(self):
161 """Load current config"""
162 self.conf.load()
163
164 def _extract_caveat_id(self, root_macaroon):
165 """Extract caveat id for the given macaroon
166 :raise InvalidCredentialsError: when given macaroon is not valid
167 """
168 macaroon = pymacaroons.Macaroon.deserialize(root_macaroon)
169 # macaroons are all bytes, never strings
170 sso_host = urllib.parse.urlparse(self.sso.root_url).netloc
171 for caveat in macaroon.caveats:
172 if caveat.location == sso_host:
173 return caveat.caveat_id
174 raise errors.InvalidCredentialsError('Invalid root macaroon')
175
176 def logout(self):
177 """Logout from Store client"""
178 self.conf.clear()
179 self.conf.save()
180
181 def _refresh_if_necessary(self, func, *args, **kwargs):
182 """Make a request, refreshing macaroons if necessary."""
183 try:
184 return func(*args, **kwargs)
185 except errors.StoreMacaroonNeedsRefreshError:
186 unbound_discharge = self.sso.refresh_unbound_discharge(
187 self.conf.get('unbound_discharge'))
188 self.conf.set('unbound_discharge', unbound_discharge)
189 self.conf.save()
190 return func(*args, **kwargs)
191
192 def get_account_information(self):
193 """Get current account information"""
194 return self._refresh_if_necessary(self.sca.get_account_information)
195
196 def register_key(self, account_key_request):
197 """Register a key for the current user"""
198 return self._refresh_if_necessary(
199 self.sca.register_key, account_key_request)
200
201 def register(self, snap_name, is_private=False):
202 """Register a package name for the current user
203 First refresh macaroon if necessary"""
204 return self._refresh_if_necessary(
205 self.sca.register, snap_name, is_private, constants.DEFAULT_SERIES)
206
207 def push_precheck(self, snap_name):
208 """Do a snap push as a pre-check (dry_run: do not actually push)
209 First refresh macaroon if necessary
210 """
211 return self._refresh_if_necessary(
212 self.sca.snap_push_precheck, snap_name)
213
214 def push_snap_build(self, snap_id, snap_build):
215 """Do a snap push
216 First refresh macaroon if necessary
217 """
218 return self._refresh_if_necessary(
219 self.sca.push_snap_build, snap_id, snap_build)
220
221 def upload(self, snap_name, snap_filename):
222 """Upload a snap file.
223 :raise InvalidCredentialsError when no unbound_discharge
224 is found in current config
225 """
226 if not self.conf.get('unbound_discharge'):
227 raise errors.InvalidCredentialsError(
228 'Unbound discharge not in the config file')
229
230 updown_data = upload_files(snap_filename, self.updown)
231
232 return self._refresh_if_necessary(
233 self.sca.snap_push_metadata, snap_name, updown_data)
234
235 def release(self, snap_name, revision, channels):
236 """Release a snap revision to the given channels
237 First refresh macaroon if necessary
238 """
239 return self._refresh_if_necessary(
240 self.sca.snap_release, snap_name, revision, channels)
241
242 def get_snap_history(self, snap_name, series=None, arch=None):
243 """Get a snap history. First refresh macaroon if necessary
244 :raise: SnapNotFoundError if the snap does not belong to
245 the current user
246 or if it is not found.
247 """
248 if series is None:
249 series = constants.DEFAULT_SERIES
250
251 account_info = self.get_account_information()
252 try:
253 snap_id = account_info['snaps'][series][snap_name]['snap-id']
254 except KeyError:
255 raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
256
257 response = self._refresh_if_necessary(
258 self.sca.snap_history, snap_id, series, arch)
259
260 if not response:
261 raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
262
263 return response
264
265 def get_snap_status(self, snap_name, series=None, arch=None):
266 """Get a snap status. First refresh macaroon if necessary
267 :raise: SnapNotFoundError if the snap does not belong
268 to the current user
269 or if it is not found.
270 """
271 if series is None:
272 series = constants.DEFAULT_SERIES
273
274 account_info = self.get_account_information()
275 try:
276 snap_id = account_info['snaps'][series][snap_name]['snap-id']
277 except KeyError:
278 raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
279
280 response = self._refresh_if_necessary(
281 self.sca.snap_status, snap_id, series, arch)
282
283 if not response:
284 raise errors.SnapNotFoundError(snap_name, series=series, arch=arch)
285
286 return response
287
288 def close_channels(self, snap_id, channel_names):
289 """Close channels for the given snap_id.
290 First refresh macaroon if necessary"""
291 return self._refresh_if_necessary(
292 self.sca.close_channels, snap_id, channel_names)
293
294 def download(self, snap_name, channel, download_path, arch=None):
295 """Download a snap and store it locally
296 :param snap_name: the snap name
297 :param channel: the channel name
298 :param download_path: the local path to download the snap
299 :param arch: the snap architecture
300 """
301 if arch is None:
302 arch = ProjectOptions().deb_arch
303
304 package = self.cpi.get_package(snap_name, channel, arch)
305 self._download_snap(
306 snap_name, download_path,
307 package['anon_download_url'], package['download_sha512'])
308
309 def _download_snap(self, name, download_path, download_url,
310 expected_sha512):
311 """Download a snap and store it locally
312 :param name: the snap name
313 :param download_path: the local path to download the snap
314 :param download_url: the url to download the snap
315 :param expected_sha512: the snap expected sha256
316 """
317 if self._is_downloaded(download_path, expected_sha512):
318 LOGGER.info('Already downloaded %s at %s', name, download_path)
319 return
320 LOGGER.info('Downloading %s at %s', name, download_path)
321 request = self.cpi.get(download_url, stream=True)
322 request.raise_for_status()
323 download_requests_stream(request, download_path)
324
325 if self._is_downloaded(download_path, expected_sha512):
326 LOGGER.info('Successfully downloaded %s at %s', name,
327 download_path)
328 else:
329 raise errors.SHAMismatchError(download_path, expected_sha512)
330
331 @staticmethod
332 def _is_downloaded(path, expected_sha512):
333 """Return True if the snap is already downloaded in the local machine
334 :param path: the path to look for the snap
335 :param expected_sha512: the expected snap sha512
336 """
337 if not os.path.exists(path):
338 return False
339
340 file_sum = hashlib.sha512()
341 with open(path, 'rb') as _f:
342 for file_chunk in iter(
343 lambda: _f.read(file_sum.block_size * 128), b''):
344 file_sum.update(file_chunk)
345 return expected_sha512 == file_sum.hexdigest()
346
347 def push_validation(self, snap_id, assertion):
348 """Push a snap validation/assertion to the store
349 :param snap_id: the snap id
350 :param assertion: the snap assertion
351 """
352 return self.sca.push_validation(snap_id, assertion)
353
354 def get_validations(self, snap_id):
355 """Get current snap validations/assertions"""
356 return self.sca.get_validations(snap_id)
357
358 def sign_developer_agreement(self, latest_tos_accepted=False):
359 """Sign developer agreement to be able to do operations in the store"""
360 return self.sca.sign_developer_agreement(latest_tos_accepted)
361
362
363class SSOClient(Client):
364 """The Single Sign On server deals with authentication.
365 It is used directly or indirectly by other servers.
366 """
367 def __init__(self, conf):
368 super().__init__(conf, os.environ.get(
369 'UBUNTU_SSO_API_ROOT_URL',
370 CONFIG_STACK.config.get('production_urls', 'sso')))
371
372 def get_unbound_discharge(self, email, password, one_time_password,
373 caveat_id):
374 """Get the unbound discharge for the caveat_id.
375 :param email: user email
376 :param password: user password
377 :param one_time_password: the one time password
378 :param caveat_id: the caveat id to get the unbound discharge
379 :raise StoreTwoFactorAuthenticationRequired:
380 if response is unauthorized and two-factor is required
381 :raise StoreAuthenticationError: if failed to get unbound discharge
382 """
383 data = dict(email=email, password=password,
384 caveat_id=caveat_id)
385 if one_time_password:
386 data['otp'] = one_time_password
387 response = self.post(
388 'tokens/discharge', data=json.dumps(data),
389 headers=JSON_HEADERS)
390 try:
391 response_json = response.json()
392 except JSONDecodeError:
393 response_json = {}
394 if response.ok:
395 return response_json['discharge_macaroon']
396 else:
397 if (response.status_code == requests.codes.get('unauthorized') and
398 any(error.get('code') == 'twofactor-required'
399 for error in response_json.get('error_list', []))):
400 raise errors.StoreTwoFactorAuthenticationRequired()
401 else:
402 raise errors.StoreAuthenticationError(
403 'Failed to get unbound discharge: {}'.format(
404 response.text))
405
406 def refresh_unbound_discharge(self, unbound_discharge):
407 """Refresh the given unbound discharge.
408 :raise StoreAuthenticationError: if fails to refresh the
409 unbound discharge.
410 """
411 data = {'discharge_macaroon': unbound_discharge}
412 response = self.post(
413 'tokens/refresh', data=json.dumps(data),
414 headers=JSON_HEADERS)
415 if response.ok:
416 return response.json()['discharge_macaroon']
417 else:
418 raise errors.StoreAuthenticationError(
419 'Failed to refresh unbound discharge: {}'.format(
420 response.text))
421
422
423class SnapIndexClient(Client):
424 """The Click Package Index knows everything about existing snaps.
425 https://wiki.ubuntu.com/AppStore/Interfaces/ClickPackageIndex is the
426 canonical reference.
427 """
428 def __init__(self, conf):
429 super().__init__(conf, os.environ.get(
430 'UBUNTU_STORE_SEARCH_ROOT_URL',
431 CONFIG_STACK.config.get('production_urls', 'search')))
432
433 def get_default_headers(self):
434 """Return default headers for CPI requests.
435 Tries to build an 'Authorization' header with local credentials
436 if they are available.
437 Also pin specific branded store if `SNAPCRAFT_UBUNTU_STORE`
438 environment is set.
439 """
440 headers = {}
441
442 with contextlib.suppress(errors.InvalidCredentialsError):
443 headers['Authorization'] = _macaroon_auth(self.conf)
444
445 branded_store = os.getenv('SNAPCRAFT_UBUNTU_STORE')
446 if branded_store:
447 headers['X-Ubuntu-Store'] = branded_store
448
449 return headers
450
451 def get_package(self, snap_name, channel, arch=None):
452 """Get snap details.
453 :param snap_name: the snap name
454 :param channel: the snap channel
455 :param arch: the snap architecture
456 """
457 headers = self.get_default_headers()
458 headers.update({
459 'Accept': 'application/hal+json',
460 'X-Ubuntu-Release': constants.DEFAULT_SERIES,
461 })
462 if arch:
463 headers['X-Ubuntu-Architecture'] = arch
464
465 params = {
466 'channel': channel,
467 'fields': 'status,anon_download_url,download_url,'
468 'download_sha512,snap_id,release',
469 }
470 LOGGER.info('Getting details for %s', snap_name)
471 url = 'api/v1/snaps/details/{}'.format(snap_name)
472 resp = super().get(url, headers=headers, params=params)
473 if resp.status_code != 200:
474 raise errors.SnapNotFoundError(snap_name, channel, arch)
475 return resp.json()
476
477
478class UpDownClient(Client):
479 """The Up/Down server provide upload/download snap capabilities."""
480
481 def __init__(self, conf):
482 super().__init__(conf, os.environ.get(
483 'UBUNTU_STORE_UPLOAD_ROOT_URL',
484 CONFIG_STACK.config.get('production_urls', 'upload')))
485
486 def upload(self, monitor):
487 """Upload a snap capability"""
488 return self.post(
489 urllib.parse.urljoin(self.root_url, 'unscanned-upload/'),
490 data=monitor,
491 headers=dict({'Content-Type': monitor.content_type},
492 **JSON_ACCEPT))
493
494
495class SCAClient(Client):
496 """The software center agent deals with managing snaps."""
497
498 def __init__(self, conf):
499 super().__init__(conf, os.environ.get(
500 'UBUNTU_STORE_API_ROOT_URL',
501 CONFIG_STACK.config.get('production_urls', 'root_api')))
502
503 def get_macaroon(self, acls, packages=None, channels=None):
504 """Get a macaroon that is used to authenticate
505 request against the store
506 :param acls: list of permissions to request the macaroon for.
507 :param packages: list of packages to request the macaroon for.
508 :param channels: list of channels to request the macaroon for.
509 """
510 data = {'permissions': acls}
511 if packages is not None:
512 data.update({'packages': packages})
513 if channels is not None:
514 data.update({'channels': channels})
515 headers = JSON_ACCEPT
516 response = self.post(
517 'acl/', json=data, headers=headers)
518 if response.ok:
519 return response.json()['macaroon']
520 else:
521 raise errors.StoreAuthenticationError('Failed to get macaroon')
522
523 @staticmethod
524 def _needs_refreshed_response(response):
525 """Return True if the given response needs to refresh the macaroon.
526 :param response: the response to check if the
527 macaroon refresh is needed.
528 """
529 return (
530 response.status_code == requests.codes.get('unauthorized') and
531 response.headers.get('WWW-Authenticate') == (
532 'Macaroon needs_refresh=1'))
533
534 def request(self, *args, **kwargs):
535 """Wrapper of client request that raises an exception for
536 the cases that the response macaroon needs to be refreshed
537 :raise StoreMacaroonNeedsRefreshError:
538 if the response needs the macaroon to be refreshed.
539 """
540 response = super().request(*args, **kwargs)
541 if self._needs_refreshed_response(response):
542 raise errors.StoreMacaroonNeedsRefreshError()
543 return response
544
545 def get_account_information(self):
546 """Get the current account information
547 :raise StoreAccountInformationError: if response is not ok status
548 """
549 auth = _macaroon_auth(self.conf)
550 response = self.get(
551 'account',
552 headers=dict({'Authorization': auth}, **JSON_ACCEPT))
553 if response.ok:
554 return response.json()
555 else:
556 raise errors.StoreAccountInformationError(response)
557
558 def register_key(self, account_key_request):
559 """Register a key for a user with the given request
560 :param account_key_request: the key to register
561 :raise StoreKeyRegistrationError
562 """
563 data = {'account_key_request': account_key_request}
564 auth = _macaroon_auth(self.conf)
565 response = self.post(
566 'account/account-key', data=json.dumps(data),
567 headers=dict({'Authorization': auth}, **JSON_HEADERS))
568 if not response.ok:
569 raise errors.StoreKeyRegistrationError(response)
570
571 def register(self, snap_name, is_private, series):
572 """Register a snap name in the store
573 :param snap_name: the name to be registered
574 :param is_private: whether the snap name is private or not
575 :param series: the snap's series to be registered
576 """
577 auth = _macaroon_auth(self.conf)
578 data = dict(snap_name=snap_name, is_private=is_private,
579 series=series)
580 response = self.post(
581 'register-name/', data=json.dumps(data),
582 headers=dict({'Authorization': auth}, **JSON_ACCEPT))
583 if not response.ok:
584 raise errors.StoreRegistrationError(snap_name, response)
585
586 def snap_push_precheck(self, snap_name):
587 """Do a snap push pre-check (dry_run: do not actually push)
588 :raise StorePushError
589 """
590 data = {
591 'name': snap_name,
592 'dry_run': True,
593 }
594 auth = _macaroon_auth(self.conf)
595 response = self.post(
596 'snap-push/', data=json.dumps(data),
597 headers=dict({'Authorization': auth}, **JSON_HEADERS))
598 if not response.ok:
599 raise errors.StorePushError(data['name'], response)
600
601 def snap_push_metadata(self, snap_name, updown_data):
602 """Push metadata fot the given snap
603 :param snap_name: the snap name which metadata want to be updated
604 :param updown_data: a dictionary that contains the metadata: upload_id,
605 binary_filesize, source_uploaded
606 :raise: StorePushError
607 """
608 data = {
609 'name': snap_name,
610 'series': constants.DEFAULT_SERIES,
611 'updown_id': updown_data['upload_id'],
612 'binary_filesize': updown_data['binary_filesize'],
613 'source_uploaded': updown_data['source_uploaded'],
614 }
615 auth = _macaroon_auth(self.conf)
616 response = self.post(
617 'snap-push/', data=json.dumps(data),
618 headers=dict({'Authorization': auth}, **JSON_HEADERS))
619 if not response.ok:
620 raise errors.StorePushError(data['name'], response)
621
622 return StatusTracker(response.json()['status_details_url'])
623
624 def snap_release(self, snap_name, revision, channels):
625 """
626 Release a snap revision to the given channel
627 :param snap_name: the snap name to be released
628 :param revision: the revision number to be released
629 :param channels: the channels in which release the snap
630 :raise: StoreReleaseError
631 """
632 data = {
633 'name': snap_name,
634 'revision': str(revision),
635 'channels': channels,
636 }
637 auth = _macaroon_auth(self.conf)
638 response = self.post(
639 'snap-release/', data=json.dumps(data),
640 headers=dict({'Authorization': auth}, **JSON_HEADERS))
641 if not response.ok:
642 raise errors.StoreReleaseError(data['name'], response)
643
644 response_json = response.json()
645
646 return response_json
647
648 def push_validation(self, snap_id, assertion):
649 """
650 Push a validation/assertion to the store
651 :param snap_id: the snap_id of the snap
652 :param assertion: the validation/assertion to be pushed
653 :raise: JSONDecodeError, StoreValidationError
654 """
655 data = {
656 'assertion': assertion.decode('utf-8'),
657 }
658 auth = _macaroon_auth(self.conf)
659 response = self.put(
660 'snaps/{}/validations'.format(snap_id), data=json.dumps(data),
661 headers=dict({'Authorization': auth}, **JSON_HEADERS))
662 if not response.ok:
663 raise errors.StoreValidationError(snap_id, response)
664 try:
665 response_json = response.json()
666 except JSONDecodeError:
667 message = ('Invalid response from the server when pushing '
668 'validations: {} {}').format(
669 response.status_code, response)
670 LOGGER.debug(message)
671 raise errors.StoreValidationError(
672 response, message='Invalid response from the server')
673
674 return response_json
675
676 def get_validations(self, snap_id):
677 """Get validations/assertion of a given snap
678 :param snap_id: the id of the snap to get the validations for.
679 :raise: JSONDecodeError, StoreValidationError
680 """
681 auth = _macaroon_auth(self.conf)
682 response = self.get(
683 'snaps/{}/validations'.format(snap_id),
684 headers=dict({'Authorization': auth}, **JSON_HEADERS))
685 if not response.ok:
686 raise errors.StoreValidationError(snap_id, response)
687 try:
688 response_json = response.json()
689 except JSONDecodeError:
690 message = ('Invalid response from the server when getting '
691 'validations: {} {}').format(
692 response.status_code, response)
693 LOGGER.debug(message)
694 raise errors.StoreValidationError(
695 response, message='Invalid response from the server')
696
697 return response_json
698
699 def push_snap_build(self, snap_id, snap_build):
700 """
701 Push a snap build to the store
702 :param snap_id: the snap id
703 :param snap_build: the snap build to push
704 :raise: StoreSnapBuildError
705 """
706 url = 'snaps/{}/builds'.format(snap_id)
707 data = json.dumps({"assertion": snap_build})
708
709 headers = dict({'Authorization': _macaroon_auth(self.conf)},
710 **JSON_CONTENT_TYPE)
711 response = self.post(url, data=data, headers=headers)
712 if not response.ok:
713 raise errors.StoreSnapBuildError(response)
714
715 def snap_history(self, snap_id, series=None, arch=None):
716 """
717 Get a snap history
718 :param snap_id: the id of the snap to get the history for.
719 :param series: the series of the snap
720 :param arch: the architecture of the snap
721 :raise: StoreSnapHistoryError
722 """
723 qs = {}
724 if series:
725 qs['series'] = series
726 if arch:
727 qs['arch'] = arch
728 url = 'snaps/' + snap_id + '/history'
729 if qs:
730 url += '?' + urllib.parse.urlencode(qs)
731 auth = _macaroon_auth(self.conf)
732 response = self.get(
733 url,
734 headers=dict({'Authorization': auth}, **JSON_HEADERS))
735 if not response.ok:
736 raise errors.StoreSnapHistoryError(response, snap_id, series, arch)
737
738 response_json = response.json()
739
740 return response_json
741
742 def snap_status(self, snap_id, series=None, arch=None):
743 """
744 Get a snap status
745 :param snap_id: the id of the snap to get the history for.
746 :param series: the series of the snap
747 :param arch: the architecture of the snap
748 :raise: StoreSnapStatusError
749 """
750 qs = {}
751 if series:
752 qs['series'] = series
753 if arch:
754 qs['arch'] = arch
755 url = 'snaps/' + snap_id + '/status'
756 if qs:
757 url += '?' + urllib.parse.urlencode(qs)
758 auth = _macaroon_auth(self.conf)
759 response = self.get(
760 url,
761 headers=dict({'Authorization': auth}, **JSON_HEADERS))
762 if not response.ok:
763 raise errors.StoreSnapStatusError(response, snap_id, series, arch)
764
765 response_json = response.json()
766
767 return response_json
768
769 def close_channels(self, snap_id, channel_names):
770 """
771 Close channel for a given snap
772 :param snap_id: the id of the snap
773 :param channel_names: the channels to be closed
774 :raise: StoreChannelClosingError
775 """
776 url = 'snaps/{}/close'.format(snap_id)
777 data = {
778 'channels': channel_names
779 }
780 headers = {
781 'Authorization': _macaroon_auth(self.conf),
782 }
783 response = self.post(url, json=data, headers=headers)
784 if not response.ok:
785 raise errors.StoreChannelClosingError(response)
786
787 try:
788 results = response.json()
789 return results['closed_channels'], results['channel_maps']
790 except (JSONDecodeError, KeyError):
791 LOGGER.debug(
792 'Invalid response from the server on channel closing:\n'
793 '%s %s\n%s', response.status_code, response.reason,
794 response.content)
795 raise errors.StoreChannelClosingError(response)
796
797 def sign_developer_agreement(self, latest_tos_accepted=False):
798 """Sign developer agreement to be able to do operations in the store"""
799 auth = _macaroon_auth(self.conf)
800 data = {'latest_tos_accepted': latest_tos_accepted}
801 response = self.post(
802 'agreement/', data=json.dumps(data),
803 headers=dict({'Authorization': auth}, **JSON_HEADERS))
804 if not response.ok:
805 raise errors.DeveloperAgreementSignError(response)
806 return response.json()
807
808
809class StatusTracker:
810
811 """Class to track the status of an operation"""
812
813 __messages = {
814 'being_processed': 'Processing...',
815 'ready_to_release': 'Ready to release!',
816 'need_manual_review': 'Will need manual review...',
817 'processing_error': 'Error while processing...',
818 }
819
820 __error_codes = (
821 'processing_error',
822 'need_manual_review',
823 )
824
825 def __init__(self, status_details_url):
826 self.__content = None
827 self.__status_details_url = status_details_url
828
829 def track(self):
830 """Track the status"""
831 queue = Queue()
832 thread = Thread(target=self._update_status, args=(queue,))
833 thread.start()
834 widgets = ['Processing...', AnimatedMarker()]
835 progress_indicator = ProgressBar(widgets=widgets, maxval=UnknownLength)
836 progress_indicator.start()
837 content = {}
838 for indicator_count in itertools.count():
839 if not queue.empty():
840 content = queue.get()
841 if isinstance(content, Exception):
842 raise Exception(content)
843 widgets[0] = self._get_message(content)
844 progress_indicator.update(indicator_count)
845 if content.get('processed'):
846 break
847 sleep(0.1)
848 progress_indicator.finish()
849 self.__content = content
850 return content
851
852 def raise_for_code(self):
853 """Raise an exception if content has particular error codes"""
854 if any(self.__content['code'] == _k for _k in self.__error_codes):
855 raise errors.StoreReviewError(self.__content)
856
857 def _get_message(self, content):
858 """Get a message from content"""
859 return self.__messages.get(content['code'], content['code'])
860
861 def _update_status(self, queue):
862 """Update an operation status"""
863 for content in self._get_status():
864 queue.put(content)
865 if content['processed']:
866 break
867 sleep(constants.SCAN_STATUS_POLL_DELAY)
868
869 def _get_status(self):
870 """Get an operation status"""
871 connection_errors_allowed = 10
872 while True:
873 try:
874 content = requests.get(self.__status_details_url).json()
875 except (requests.ConnectionError, requests.HTTPError) as _e:
876 if not connection_errors_allowed:
877 yield _e
878 content = {'processed': False, 'code': 'being_processed'}
879 connection_errors_allowed -= 1
880 yield content
0881
=== added file 'snappy_ecosystem_tests/helpers/store_apis/upload.py'
--- snappy_ecosystem_tests/helpers/store_apis/upload.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/helpers/store_apis/upload.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,97 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Helpers to upload binary files to the store"""
22
23import logging
24import functools
25import os
26
27from progressbar import (
28 Bar,
29 Percentage,
30 ProgressBar,
31)
32from requests_toolbelt import (MultipartEncoder, MultipartEncoderMonitor)
33
34from snappy_ecosystem_tests.helpers.store_apis.errors import StoreUploadError
35
36LOGGER = logging.getLogger(__name__)
37
38
39def _update_progress_bar(progress_bar, maximum_value, monitor):
40 """Update the progress bar status"""
41 if monitor.bytes_read <= maximum_value:
42 progress_bar.update(monitor.bytes_read)
43
44
45def upload_files(binary_filename, updown_client):
46 """Upload a binary file to the Store.
47 Submit a file to the Store upload service and return the
48 corresponding upload_id.
49 """
50 binary_file = None
51 try:
52 binary_file_size = os.path.getsize(binary_filename)
53 binary_file = open(binary_filename, 'rb')
54 encoder = MultipartEncoder(
55 fields={
56 'binary': ('filename', binary_file, 'application/octet-stream')
57 }
58 )
59
60 # Create a progress bar that looks like: Uploading foo [== ] 50%
61 progress_bar = ProgressBar(
62 widgets=['Uploading {} '.format(binary_filename),
63 Bar(marker='=', left='[', right=']'), ' ', Percentage()],
64 maxval=os.path.getsize(binary_filename))
65 progress_bar.start()
66 # Print a newline so the progress bar has some breathing room.
67 LOGGER.info('')
68
69 # Create a monitor for this upload, so that progress can be displayed
70 monitor = MultipartEncoderMonitor(
71 encoder, functools.partial(_update_progress_bar, progress_bar,
72 binary_file_size))
73
74 # Begin upload
75 response = updown_client.upload(monitor)
76
77 # Make sure progress bar shows 100% complete
78 progress_bar.finish()
79
80 except Exception as err:
81 raise RuntimeError(
82 'An unexpected error was found while uploading '
83 'files: {!r}.'.format(err))
84 finally:
85 # Close the open file
86 if binary_file:
87 binary_file.close()
88
89 if not response.ok:
90 raise StoreUploadError(response)
91
92 response_data = response.json()
93 return {
94 'upload_id': response_data['upload_id'],
95 'binary_filesize': binary_file_size,
96 'source_uploaded': False,
97 }
098
=== removed directory 'snappy_ecosystem_tests/snapcraft'
=== removed file 'snappy_ecosystem_tests/snapcraft/__init__.py'
--- snappy_ecosystem_tests/snapcraft/__init__.py 2017-02-09 20:09:40 +0000
+++ snappy_ecosystem_tests/snapcraft/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,19 +0,0 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
200
=== removed file 'snappy_ecosystem_tests/snapcraft/snapcraft.py'
--- snappy_ecosystem_tests/snapcraft/snapcraft.py 2017-02-15 19:14:19 +0000
+++ snappy_ecosystem_tests/snapcraft/snapcraft.py 1970-01-01 00:00:00 +0000
@@ -1,74 +0,0 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Snapcraft client helpers"""
22
23import pexpect
24
25
26# login credentials exported by shell environment
27from snappy_ecosystem_tests.utils import storeconfig
28
29LOGIN_EMAIL, LOGIN_PASSWORD = storeconfig.get_store_credentials()
30
31
32class Snapcraft(object):
33 """Contain Snapcraft specific functionality to use via command
34 line interface"""
35 def __init__(self):
36 """Create new snapcraft instance."""
37 self._login = False
38 self._cleanup()
39
40 def _cleanup(self):
41 """Perform cleanup actions"""
42 self.logout()
43
44 def logout(self):
45 """logout of snapcraft store session"""
46 child = pexpect.spawn("snapcraft logout")
47 err = child.expect('Credentials cleared.')
48 child.terminate(True)
49 child.wait()
50 if err is not 0:
51 raise ValueError("Failed to logout")
52
53 self._login = False
54
55 def login(self):
56 """login to store using the credential environment variables"""
57 child = pexpect.spawn("snapcraft login")
58 child.expect('Email: ')
59 child.sendline(LOGIN_EMAIL)
60 child.expect('Password:')
61 child.sendline(LOGIN_PASSWORD)
62 err = child.expect('Login successful')
63 if err is not 0:
64 raise ValueError("Failed to login")
65
66 self._login = True
67
68 def list_registered(self):
69 """call snapcraft list-registered command,
70 raise exception if not logged in"""
71 if self._login is False:
72 raise ValueError("User is not logged in, "
73 "please login before using this command")
74 return pexpect.spawnu("snapcraft list-registered").read()
750
=== added file 'snappy_ecosystem_tests/tests/test_store_apis_login.py'
--- snappy_ecosystem_tests/tests/test_store_apis_login.py 1970-01-01 00:00:00 +0000
+++ snappy_ecosystem_tests/tests/test_store_apis_login.py 2017-02-20 17:48:06 +0000
@@ -0,0 +1,39 @@
1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
2
3#
4# Snappy Ecosystem Tests
5# Copyright (C) 2017 Canonical
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19#
20
21"""Test for Login to the Store via RESTful APIs"""
22
23from snappy_ecosystem_tests.helpers.store_apis.rest_apis import Store
24from snappy_ecosystem_tests.helpers.test_base import SnappyEcosystemTestCase
25from snappy_ecosystem_tests.utils.storeconfig import get_store_credentials
26
27
28class StoreAPILoginTestCase(SnappyEcosystemTestCase):
29
30 def setUp(self):
31 super().setUp()
32 self.client = Store()
33
34 def test_api_login_logout(self):
35 """Test login and logout functionality in store REST APIs"""
36 self.client.login(*get_store_credentials())
37 self.client.load_config()
38 self.assertIsNotNone(self.client.config.get('macaroon'))
39 self.assertIsNotNone(self.client.config.get('unbound_discharge'))
040
=== modified file 'snappy_ecosystem_tests/tests/test_store_login.py'
--- snappy_ecosystem_tests/tests/test_store_login.py 2017-02-15 19:14:19 +0000
+++ snappy_ecosystem_tests/tests/test_store_login.py 2017-02-20 17:48:06 +0000
@@ -20,7 +20,7 @@
2020
21"""Test for Login to the Store via Web Interface, snapcraft and snapd"""21"""Test for Login to the Store via Web Interface, snapcraft and snapd"""
2222
23from snappy_ecosystem_tests.snapcraft.snapcraft import Snapcraft23from snappy_ecosystem_tests.helpers.snapcraft.client import Snapcraft
24from snappy_ecosystem_tests.helpers.web_test_base import UbuntuStoreWebTestsBase24from snappy_ecosystem_tests.helpers.web_test_base import UbuntuStoreWebTestsBase
2525
2626

Subscribers

People subscribed via source and target branches