Merge lp:~canonical-platform-qa/snappy-ecosystem-tests/assertions-signing-mechanism into lp:snappy-ecosystem-tests
- assertions-signing-mechanism
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Heber Parrucci |
Approved revision: | 56 |
Merged at revision: | 58 |
Proposed branch: | lp:~canonical-platform-qa/snappy-ecosystem-tests/assertions-signing-mechanism |
Merge into: | lp:snappy-ecosystem-tests |
Diff against target: |
736 lines (+569/-9) 10 files modified
README.rst (+13/-2) pylint.cfg (+2/-2) snappy_ecosystem_tests/helpers/snapcraft/client.py (+80/-0) snappy_ecosystem_tests/helpers/snapd/snapd.py (+125/-0) snappy_ecosystem_tests/helpers/store_apis/rest_apis.py (+34/-0) snappy_ecosystem_tests/models/assertion.py (+116/-0) snappy_ecosystem_tests/models/key.py (+41/-0) snappy_ecosystem_tests/models/snap.py (+4/-0) snappy_ecosystem_tests/tests/test_share_snap.py (+143/-0) snappy_ecosystem_tests/utils/storeconfig.py (+11/-5) |
To merge this branch: | bzr merge lp:~canonical-platform-qa/snappy-ecosystem-tests/assertions-signing-mechanism |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
platform-qa-bot | continuous-integration | Approve | |
Snappy ecosystem tests developer | Pending | ||
Review via email:
|
Commit message
Adding a generic mechanism to sign assertions
Description of the change
The objective of this change is to provide a mechanism for generating and signing user's assertions. That will be useful, for example for sharing snaps to other users.
The change also contains a test for sharing a snap that is currently skipped because the assertion type: 'snap-developer' is not released yet. So if you run the test you will get:
RuntimeError: Unable to sign assertion: spawn /tmp/tmp.
error: invalid assertion type: snap-developer
Important note: To allow supporting multiple users, a change was made in get_remote_
[store_user_0]
user_email=
user_password=
[store_user_1]
user_email=
user_password=
[user]
snapd_hostname_
snapd_username_
snapd_port_remote=
snapcraft_
snapcraft_
snapcraft_
See README file for more details.
@run_tests: snappy_
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
platform-qa-bot (platform-qa-bot) wrote : | # |
PASSED: Continuous integration, rev:55
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
- 56. 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:56
https:/
Executed test runs:
UNSTABLE: https:/
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:56
https:/
Executed test runs:
UNSTABLE: https:/
SUCCESS: https:/
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 : | # |
PASSED: Continuous integration, rev:56
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Preview Diff
1 | === modified file 'README.rst' | |||
2 | --- README.rst 2017-03-27 13:16:17 +0000 | |||
3 | +++ README.rst 2017-04-13 13:43:35 +0000 | |||
4 | @@ -66,9 +66,20 @@ | |||
5 | 66 | XDG_USER_CONFIG_HOME=^DIR_PATH^ | 66 | XDG_USER_CONFIG_HOME=^DIR_PATH^ |
6 | 67 | 67 | ||
7 | 68 | The config file should look like: | 68 | The config file should look like: |
8 | 69 | |||
9 | 70 | [store_user_0] | ||
10 | 71 | user_email=^USER_0_NAME^ | ||
11 | 72 | user_password=^USER_0_PASSWORD^ | ||
12 | 73 | |||
13 | 74 | [store_user_1] | ||
14 | 75 | user_email=^USER_1_NAME^ | ||
15 | 76 | user_password=^USER_1_PASSWORD^ | ||
16 | 77 | |||
17 | 78 | [store_user_n] | ||
18 | 79 | user_email=^USER_n_NAME^ | ||
19 | 80 | user_password=^USER_n_PASSWORD^ | ||
20 | 81 | |||
21 | 69 | [user] | 82 | [user] |
22 | 70 | user_email=^USER_NAME^ | ||
23 | 71 | user_password=^USER_PASSWORD^ | ||
24 | 72 | snapd_hostname_remote=^SNAPD_SSH_HOSTNAME^ | 83 | snapd_hostname_remote=^SNAPD_SSH_HOSTNAME^ |
25 | 73 | snapd_username_remote=^SNAPD_SSH_USERNAME^ | 84 | snapd_username_remote=^SNAPD_SSH_USERNAME^ |
26 | 74 | snapd_port_remote=^SNAPD_SSH_PORT^ | 85 | snapd_port_remote=^SNAPD_SSH_PORT^ |
27 | 75 | 86 | ||
28 | === modified file 'pylint.cfg' | |||
29 | --- pylint.cfg 2017-03-29 17:35:36 +0000 | |||
30 | +++ pylint.cfg 2017-04-13 13:43:35 +0000 | |||
31 | @@ -206,7 +206,7 @@ | |||
32 | 206 | 206 | ||
33 | 207 | # Regular expression which should only match function or class names that do | 207 | # Regular expression which should only match function or class names that do |
34 | 208 | # not require a docstring. | 208 | # not require a docstring. |
36 | 209 | no-docstring-rgx=.*TestCase$|setUp|tearDown | 209 | no-docstring-rgx=.*TestCase$|setUp|tearDown|__getattr__ |
37 | 210 | 210 | ||
38 | 211 | # Minimum line length for functions/classes that require docstrings, shorter | 211 | # Minimum line length for functions/classes that require docstrings, shorter |
39 | 212 | # ones are exempt. | 212 | # ones are exempt. |
40 | @@ -364,7 +364,7 @@ | |||
41 | 364 | min-public-methods=1 | 364 | min-public-methods=1 |
42 | 365 | 365 | ||
43 | 366 | # Maximum number of public methods for a class (see R0904). | 366 | # Maximum number of public methods for a class (see R0904). |
45 | 367 | max-public-methods=20 | 367 | max-public-methods=25 |
46 | 368 | 368 | ||
47 | 369 | # Maximum number of boolean expressions in a if statement | 369 | # Maximum number of boolean expressions in a if statement |
48 | 370 | max-bool-expr=5 | 370 | max-bool-expr=5 |
49 | 371 | 371 | ||
50 | === modified file 'snappy_ecosystem_tests/helpers/snapcraft/client.py' | |||
51 | --- snappy_ecosystem_tests/helpers/snapcraft/client.py 2017-04-04 20:17:51 +0000 | |||
52 | +++ snappy_ecosystem_tests/helpers/snapcraft/client.py 2017-04-13 13:43:35 +0000 | |||
53 | @@ -27,6 +27,7 @@ | |||
54 | 27 | 27 | ||
55 | 28 | from pathlib import PurePath | 28 | from pathlib import PurePath |
56 | 29 | 29 | ||
57 | 30 | from snappy_ecosystem_tests.models.key import Key | ||
58 | 30 | from snappy_ecosystem_tests.models.snap import Snap | 31 | from snappy_ecosystem_tests.models.snap import Snap |
59 | 31 | from snappy_ecosystem_tests.utils.commands import build_command | 32 | from snappy_ecosystem_tests.utils.commands import build_command |
60 | 32 | from snappy_ecosystem_tests.utils.filters import filter_list | 33 | from snappy_ecosystem_tests.utils.filters import filter_list |
61 | @@ -53,12 +54,36 @@ | |||
62 | 53 | -c 'interact'\ | 54 | -c 'interact'\ |
63 | 54 | """ | 55 | """ |
64 | 55 | 56 | ||
65 | 57 | COMMANDS_CREATE_KEY = """\ | ||
66 | 58 | /usr/bin/expect \ | ||
67 | 59 | -c 'spawn {snapcraft} create-key {name}' \ | ||
68 | 60 | -c 'expect Passphrase:' \ | ||
69 | 61 | -c 'send {passphrase}\\r' \ | ||
70 | 62 | -c 'expect Confirm passphrase:' \ | ||
71 | 63 | -c 'send {passphrase}\\r' \ | ||
72 | 64 | -c 'interact'\ | ||
73 | 65 | """ | ||
74 | 66 | |||
75 | 67 | COMMANDS_REGISTER_KEY = """\ | ||
76 | 68 | /usr/bin/expect \ | ||
77 | 69 | -c 'spawn {snapcraft} register-key {name}' \ | ||
78 | 70 | -c 'expect Email:' \ | ||
79 | 71 | -c 'send {email}\\r' \ | ||
80 | 72 | -c 'expect Password:' \ | ||
81 | 73 | -c 'send {password}\\r' \ | ||
82 | 74 | -c 'expect Enter passphrase:' \ | ||
83 | 75 | -c 'send {passphrase}\\r' \ | ||
84 | 76 | -c 'interact'\ | ||
85 | 77 | """ | ||
86 | 78 | |||
87 | 56 | COMMAND_LOGOUT = 'logout' | 79 | COMMAND_LOGOUT = 'logout' |
88 | 57 | 80 | ||
89 | 58 | COMMAND_REGISTER = 'register' | 81 | COMMAND_REGISTER = 'register' |
90 | 59 | COMMAND_LIST_REGISTERED = 'list-registered' | 82 | COMMAND_LIST_REGISTERED = 'list-registered' |
91 | 83 | COMMAND_LIST_KEYS = 'keys' | ||
92 | 60 | COMMAND_PUSH = 'push' | 84 | COMMAND_PUSH = 'push' |
93 | 61 | COMMAND_RELEASE = 'release' | 85 | COMMAND_RELEASE = 'release' |
94 | 86 | |||
95 | 62 | LOGGER = logging.getLogger(__name__) | 87 | LOGGER = logging.getLogger(__name__) |
96 | 63 | 88 | ||
97 | 64 | HOSTNAME, USERNAME, PORT = get_snapcraft_remote_host_credentials() | 89 | HOSTNAME, USERNAME, PORT = get_snapcraft_remote_host_credentials() |
98 | @@ -350,6 +375,61 @@ | |||
99 | 350 | self.ssh.run_command('touch {}'.format(file_name), cwd=tempdir) | 375 | self.ssh.run_command('touch {}'.format(file_name), cwd=tempdir) |
100 | 351 | return os.path.join(tempdir, file_name) | 376 | return os.path.join(tempdir, file_name) |
101 | 352 | 377 | ||
102 | 378 | def create_key(self, name, passphrase='default', register=True, | ||
103 | 379 | email=LOGIN_EMAIL, password=LOGIN_PASSWORD): | ||
104 | 380 | """Create a user key | ||
105 | 381 | |||
106 | 382 | :param name: the key name | ||
107 | 383 | :param passphrase: the key passphrase | ||
108 | 384 | :param register: whether to register the key in the store | ||
109 | 385 | :param email: user email | ||
110 | 386 | :param password: user password | ||
111 | 387 | """ | ||
112 | 388 | key = self.get_key(name) | ||
113 | 389 | if key and key.is_registered(): | ||
114 | 390 | LOGGER.info('Key %s exists and is registered', name) | ||
115 | 391 | return | ||
116 | 392 | elif not key: | ||
117 | 393 | LOGGER.info('Key %s does not exist. Creating...', name) | ||
118 | 394 | self.ssh.run_command( | ||
119 | 395 | COMMANDS_CREATE_KEY.format(name=name, | ||
120 | 396 | passphrase=passphrase, | ||
121 | 397 | snapcraft=PATH_SNAPCRAFT)) | ||
122 | 398 | if register: | ||
123 | 399 | self.register_key(name, email, password) | ||
124 | 400 | |||
125 | 401 | def register_key(self, name, passphrase='default', | ||
126 | 402 | email=LOGIN_EMAIL, password=LOGIN_PASSWORD): | ||
127 | 403 | """Register a key in the store | ||
128 | 404 | |||
129 | 405 | :param name: the key name | ||
130 | 406 | :param passphrase: the key passphrase | ||
131 | 407 | :param email: the user email | ||
132 | 408 | :param password: the user password | ||
133 | 409 | """ | ||
134 | 410 | LOGGER.info('Registering key %s.', name) | ||
135 | 411 | return self.ssh.run_command( | ||
136 | 412 | COMMANDS_REGISTER_KEY.format(email=email, | ||
137 | 413 | password=password, | ||
138 | 414 | name=name, | ||
139 | 415 | passphrase=passphrase, | ||
140 | 416 | snapcraft=PATH_SNAPCRAFT)) | ||
141 | 417 | |||
142 | 418 | def get_key(self, name): | ||
143 | 419 | """Get a user key | ||
144 | 420 | |||
145 | 421 | :param name: key name | ||
146 | 422 | :return: The key instance if found, None otherwise | ||
147 | 423 | """ | ||
148 | 424 | user_keys = self._run_snapcraft_command_ssh( | ||
149 | 425 | COMMAND_LIST_KEYS).splitlines() | ||
150 | 426 | for line in user_keys: | ||
151 | 427 | if name in line: | ||
152 | 428 | registered = False if 'not registered' in line.lower() else True | ||
153 | 429 | fingerprint = line.split()[2] | ||
154 | 430 | return Key(name, fingerprint, registered) | ||
155 | 431 | return None | ||
156 | 432 | |||
157 | 353 | 433 | ||
158 | 354 | def build_snapcraft_command(*args, base_command=PATH_SNAPCRAFT): | 434 | def build_snapcraft_command(*args, base_command=PATH_SNAPCRAFT): |
159 | 355 | """ | 435 | """ |
160 | 356 | 436 | ||
161 | === modified file 'snappy_ecosystem_tests/helpers/snapd/snapd.py' | |||
162 | --- snappy_ecosystem_tests/helpers/snapd/snapd.py 2017-03-22 12:29:03 +0000 | |||
163 | +++ snappy_ecosystem_tests/helpers/snapd/snapd.py 2017-04-13 13:43:35 +0000 | |||
164 | @@ -22,10 +22,13 @@ | |||
165 | 22 | 22 | ||
166 | 23 | import json | 23 | import json |
167 | 24 | import logging | 24 | import logging |
168 | 25 | import os | ||
169 | 25 | from time import sleep | 26 | from time import sleep |
170 | 26 | 27 | ||
171 | 27 | import yaml | 28 | import yaml |
172 | 28 | 29 | ||
173 | 30 | from snappy_ecosystem_tests.models.assertion import AssertionFactory | ||
174 | 31 | from snappy_ecosystem_tests.models.key import Key | ||
175 | 29 | from snappy_ecosystem_tests.utils.ssh import SSHManager | 32 | from snappy_ecosystem_tests.utils.ssh import SSHManager |
176 | 30 | from snappy_ecosystem_tests.utils.user import get_snapd_remote_host_credentials | 33 | from snappy_ecosystem_tests.utils.user import get_snapd_remote_host_credentials |
177 | 31 | 34 | ||
178 | @@ -39,7 +42,9 @@ | |||
179 | 39 | COMMAND_LOGOUT = 'logout' | 42 | COMMAND_LOGOUT = 'logout' |
180 | 40 | COMMAND_REFRESH = 'refresh {snap} --channel={channel}' | 43 | COMMAND_REFRESH = 'refresh {snap} --channel={channel}' |
181 | 41 | COMMAND_REMOVE = 'remove {snap}' | 44 | COMMAND_REMOVE = 'remove {snap}' |
182 | 45 | COMMAND_SIGN = 'sign' | ||
183 | 42 | CHANNEL_STABLE = 'stable' | 46 | CHANNEL_STABLE = 'stable' |
184 | 47 | COMMAND_LIST_KEYS = 'keys' | ||
185 | 43 | COMMANDS_LOGIN = """\ | 48 | COMMANDS_LOGIN = """\ |
186 | 44 | sudo /usr/bin/expect \ | 49 | sudo /usr/bin/expect \ |
187 | 45 | -c 'spawn snap login {email}' \ | 50 | -c 'spawn snap login {email}' \ |
188 | @@ -47,6 +52,33 @@ | |||
189 | 47 | -c 'send {password}\\r' \ | 52 | -c 'send {password}\\r' \ |
190 | 48 | -c 'interact'\ | 53 | -c 'interact'\ |
191 | 49 | """ | 54 | """ |
192 | 55 | |||
193 | 56 | COMMANDS_CREATE_KEY = """\ | ||
194 | 57 | /usr/bin/expect \ | ||
195 | 58 | -c 'spawn {snap} create-key {name}' \ | ||
196 | 59 | -c 'expect Passphrase:' \ | ||
197 | 60 | -c 'send {passphrase}\\r' \ | ||
198 | 61 | -c 'expect Confirm passphrase:' \ | ||
199 | 62 | -c 'send {passphrase}\\r' \ | ||
200 | 63 | -c 'interact'\ | ||
201 | 64 | """ | ||
202 | 65 | |||
203 | 66 | COMMANDS_SIGN_KEY = """\ | ||
204 | 67 | /usr/bin/expect \ | ||
205 | 68 | -c 'spawn {sign_script}' \ | ||
206 | 69 | -c 'expect Enter passphrase:' \ | ||
207 | 70 | -c 'send {passphrase}\\r' \ | ||
208 | 71 | -c 'interact'\ | ||
209 | 72 | """ | ||
210 | 73 | |||
211 | 74 | COMMANDS_EXPORT_KEY = """\ | ||
212 | 75 | /usr/bin/expect \ | ||
213 | 76 | -c 'spawn {export_key_script}' \ | ||
214 | 77 | -c 'expect Enter passphrase:' \ | ||
215 | 78 | -c 'send {passphrase}\\r' \ | ||
216 | 79 | -c 'interact'\ | ||
217 | 80 | """ | ||
218 | 81 | |||
219 | 50 | LOGGER = logging.getLogger(__name__) | 82 | LOGGER = logging.getLogger(__name__) |
220 | 51 | 83 | ||
221 | 52 | HOSTNAME, USERNAME, PORT = get_snapd_remote_host_credentials() | 84 | HOSTNAME, USERNAME, PORT = get_snapd_remote_host_credentials() |
222 | @@ -203,3 +235,96 @@ | |||
223 | 203 | else: | 235 | else: |
224 | 204 | raise ValueError('Snap not published, waited {} seconds.'.format( | 236 | raise ValueError('Snap not published, waited {} seconds.'.format( |
225 | 205 | retry_attempts * retry_interval)) | 237 | retry_attempts * retry_interval)) |
226 | 238 | |||
227 | 239 | def create_key(self, name, passphrase='default'): | ||
228 | 240 | """Create a key if if it does not exists | ||
229 | 241 | :param name: the key name | ||
230 | 242 | :param passphrase: the key passphrase | ||
231 | 243 | :return: the instance of the key | ||
232 | 244 | """ | ||
233 | 245 | if not self.get_key(name): | ||
234 | 246 | self.ssh.run_command( | ||
235 | 247 | COMMANDS_CREATE_KEY.format(name=name, | ||
236 | 248 | passphrase=passphrase, | ||
237 | 249 | snap=PATH_SNAP)) | ||
238 | 250 | return self.get_key(name) | ||
239 | 251 | |||
240 | 252 | def get_key(self, name): | ||
241 | 253 | """Get a user key | ||
242 | 254 | |||
243 | 255 | :param name: key name | ||
244 | 256 | :return: The key instance if found, None otherwise | ||
245 | 257 | """ | ||
246 | 258 | user_keys = self.run_snapd_command_ssh(COMMAND_LIST_KEYS).splitlines() | ||
247 | 259 | for line in user_keys: | ||
248 | 260 | if name in line: | ||
249 | 261 | _name, fingerprint = line.split() | ||
250 | 262 | return Key(_name, fingerprint) | ||
251 | 263 | return None | ||
252 | 264 | |||
253 | 265 | def export_key(self, account_id, name, passphrase='default'): | ||
254 | 266 | """Export a user's key | ||
255 | 267 | |||
256 | 268 | :param account_id: user's account_id | ||
257 | 269 | :param name: name of the key to be exported | ||
258 | 270 | :param passphrase: the passphrase to unlock the key | ||
259 | 271 | :return: | ||
260 | 272 | """ | ||
261 | 273 | tempdir = self.ssh.run_command('mktemp -d') | ||
262 | 274 | self.ssh.run_command( | ||
263 | 275 | 'echo "{snap} export-key --account={account} {key} > assertion_key"' | ||
264 | 276 | ' > export_key.sh'.format( | ||
265 | 277 | snap=PATH_SNAP, | ||
266 | 278 | account=account_id, | ||
267 | 279 | key=name), | ||
268 | 280 | cwd=tempdir) | ||
269 | 281 | script_path = os.path.join(tempdir, 'export_key.sh') | ||
270 | 282 | self.ssh.run_command('chmod +x {}'.format(script_path)) | ||
271 | 283 | self.ssh.run_command( | ||
272 | 284 | COMMANDS_EXPORT_KEY.format( | ||
273 | 285 | export_key_script=script_path, | ||
274 | 286 | passphrase=passphrase), cwd=tempdir) | ||
275 | 287 | return self.ssh.run_command('cat {}'.format( | ||
276 | 288 | os.path.join(tempdir, 'assertion_key'))) | ||
277 | 289 | |||
278 | 290 | def build_assertion(self, _type, **kwargs): | ||
279 | 291 | """ | ||
280 | 292 | Build a new assertion in the remote machine | ||
281 | 293 | |||
282 | 294 | :param _type: the assertion type | ||
283 | 295 | :param kwargs: the arguments for the given assertion type | ||
284 | 296 | :return: the remote full path of the assertion | ||
285 | 297 | """ | ||
286 | 298 | assertion = AssertionFactory.create(_type, **kwargs) | ||
287 | 299 | tempdir = self.ssh.run_command('mktemp -d') | ||
288 | 300 | self.ssh.run_command( | ||
289 | 301 | 'echo {} > assertion'.format(str(assertion)), cwd=tempdir) | ||
290 | 302 | return os.path.join(tempdir, 'assertion') | ||
291 | 303 | |||
292 | 304 | def sign_assertion(self, assertion, key='default', passphrase='default'): | ||
293 | 305 | """Sign an assertion with a developer private key | ||
294 | 306 | |||
295 | 307 | :param assertion: the path of the assertion to be signed | ||
296 | 308 | :param key: the developer's private key. | ||
297 | 309 | :param passphrase: passphrase to unlock the key | ||
298 | 310 | """ | ||
299 | 311 | tempdir = self.ssh.run_command('mktemp -d') | ||
300 | 312 | self.ssh.run_command( | ||
301 | 313 | 'echo "cat {} | {} {} -k {} > assertion_signed" ' | ||
302 | 314 | '> sign_assertion.sh'.format( | ||
303 | 315 | assertion, | ||
304 | 316 | PATH_SNAP, | ||
305 | 317 | COMMAND_SIGN, | ||
306 | 318 | key), | ||
307 | 319 | cwd=tempdir) | ||
308 | 320 | script_path = os.path.join(tempdir, 'sign_assertion.sh') | ||
309 | 321 | self.ssh.run_command('chmod +x {}'.format(script_path)) | ||
310 | 322 | output = self.ssh.run_command( | ||
311 | 323 | COMMANDS_SIGN_KEY.format( | ||
312 | 324 | sign_script=script_path, | ||
313 | 325 | passphrase=passphrase), cwd=tempdir) | ||
314 | 326 | if 'invalid assertion' not in output.lower(): | ||
315 | 327 | return self.ssh.run_command('cat {}'.format( | ||
316 | 328 | os.path.join(tempdir, 'assertion_signed'))) | ||
317 | 329 | else: | ||
318 | 330 | raise RuntimeError('Unable to sign assertion: {}'.format(output)) | ||
319 | 206 | 331 | ||
320 | === modified file 'snappy_ecosystem_tests/helpers/store_apis/rest_apis.py' | |||
321 | --- snappy_ecosystem_tests/helpers/store_apis/rest_apis.py 2017-03-29 17:35:36 +0000 | |||
322 | +++ snappy_ecosystem_tests/helpers/store_apis/rest_apis.py 2017-04-13 13:43:35 +0000 | |||
323 | @@ -398,6 +398,12 @@ | |||
324 | 398 | """Sign developer agreement to be able to do operations in the store""" | 398 | """Sign developer agreement to be able to do operations in the store""" |
325 | 399 | return self.sca.sign_developer_agreement(latest_tos_accepted) | 399 | return self.sca.sign_developer_agreement(latest_tos_accepted) |
326 | 400 | 400 | ||
327 | 401 | def share_snap(self, snap_name, assertion, series=None): | ||
328 | 402 | """Share a snap with the given assertion | ||
329 | 403 | First refresh macaroon if necessary""" | ||
330 | 404 | return self._refresh_if_necessary( | ||
331 | 405 | self.sca.share_snap, snap_name, assertion, series) | ||
332 | 406 | |||
333 | 401 | 407 | ||
334 | 402 | class SSOClient(Client): | 408 | class SSOClient(Client): |
335 | 403 | """The Single Sign On server deals with authentication. | 409 | """The Single Sign On server deals with authentication. |
336 | @@ -874,6 +880,34 @@ | |||
337 | 874 | raise errors.DeveloperAgreementSignError(response) | 880 | raise errors.DeveloperAgreementSignError(response) |
338 | 875 | return response.json() | 881 | return response.json() |
339 | 876 | 882 | ||
340 | 883 | def share_snap(self, snap_name, assertion, series=None): | ||
341 | 884 | """ | ||
342 | 885 | Share a snap trough the given signed assertion. | ||
343 | 886 | |||
344 | 887 | :param snap_name: name of the snap to be shared. | ||
345 | 888 | :param assertion: the snap-developer assertion. | ||
346 | 889 | The assertion’s publisher-id must be the authorized user’s account ID. | ||
347 | 890 | The assertion’s authority-id must be the same as the publisher-id, | ||
348 | 891 | i.e. the assertion must be signed by one of the authorized user’s | ||
349 | 892 | private keys. | ||
350 | 893 | :param series: the snap series | ||
351 | 894 | """ | ||
352 | 895 | if series is None: | ||
353 | 896 | series = constants.DEFAULT_SERIES | ||
354 | 897 | snap_id = self.filter_snaps(series=series, name=snap_name)[0].get_id() | ||
355 | 898 | url = 'snaps/' + snap_id + '/developers' | ||
356 | 899 | auth = _macaroon_auth(self.conf) | ||
357 | 900 | # TODO: Investigate if it is necessary to push the assertion | ||
358 | 901 | # before calling to the API | ||
359 | 902 | data = { | ||
360 | 903 | 'snap_developer': assertion, | ||
361 | 904 | } | ||
362 | 905 | response = self.put( | ||
363 | 906 | url, | ||
364 | 907 | headers=dict(**{'Authorization': auth}, **JSON_HEADERS), | ||
365 | 908 | data=json.dumps(data)) | ||
366 | 909 | return response | ||
367 | 910 | |||
368 | 877 | 911 | ||
369 | 878 | class StatusTracker: | 912 | class StatusTracker: |
370 | 879 | 913 | ||
371 | 880 | 914 | ||
372 | === added file 'snappy_ecosystem_tests/models/assertion.py' | |||
373 | --- snappy_ecosystem_tests/models/assertion.py 1970-01-01 00:00:00 +0000 | |||
374 | +++ snappy_ecosystem_tests/models/assertion.py 2017-04-13 13:43:35 +0000 | |||
375 | @@ -0,0 +1,116 @@ | |||
376 | 1 | # -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- | ||
377 | 2 | |||
378 | 3 | # | ||
379 | 4 | # Snappy Ecosystem Tests | ||
380 | 5 | # Copyright (C) 2017 Canonical | ||
381 | 6 | # | ||
382 | 7 | # This program is free software: you can redistribute it and/or modify | ||
383 | 8 | # it under the terms of the GNU General Public License as published by | ||
384 | 9 | # the Free Software Foundation, either version 3 of the License, or | ||
385 | 10 | # (at your option) any later version. | ||
386 | 11 | # | ||
387 | 12 | # This program is distributed in the hope that it will be useful, | ||
388 | 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
389 | 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
390 | 15 | # GNU General Public License for more details. | ||
391 | 16 | # | ||
392 | 17 | # You should have received a copy of the GNU General Public License | ||
393 | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
394 | 19 | # | ||
395 | 20 | |||
396 | 21 | """Business model classes for assertions""" | ||
397 | 22 | |||
398 | 23 | import json | ||
399 | 24 | |||
400 | 25 | from datetime import datetime | ||
401 | 26 | |||
402 | 27 | |||
403 | 28 | class Assertion(dict): | ||
404 | 29 | """Represents an assertion entity.""" | ||
405 | 30 | |||
406 | 31 | def __init__(self, _type): | ||
407 | 32 | super().__init__() | ||
408 | 33 | self.type = _type | ||
409 | 34 | |||
410 | 35 | def __getattr__(self, item): | ||
411 | 36 | return self.get(item) | ||
412 | 37 | |||
413 | 38 | def __setattr__(self, key, value): | ||
414 | 39 | self[key] = value | ||
415 | 40 | |||
416 | 41 | def __str__(self, encoding='utf-8'): | ||
417 | 42 | return str(json.dumps(self).encode(encoding=encoding)).lstrip('b') | ||
418 | 43 | |||
419 | 44 | |||
420 | 45 | class SnapDeveloperAssertion(Assertion): | ||
421 | 46 | """Represents a snap developer assertion entity.""" | ||
422 | 47 | |||
423 | 48 | def __init__(self, snap_id, developers, | ||
424 | 49 | revision, publisher_id='canonical', authority_id=None): | ||
425 | 50 | super().__init__('snap-developer') | ||
426 | 51 | self['publisher-id'] = publisher_id | ||
427 | 52 | self['authority-id'] = authority_id or publisher_id | ||
428 | 53 | self['snap-id'] = snap_id | ||
429 | 54 | self['revision'] = revision | ||
430 | 55 | self._parse_developers(developers) | ||
431 | 56 | |||
432 | 57 | def _parse_developers(self, _developers): | ||
433 | 58 | """Parse the developers list to a well-known format for the assertion. | ||
434 | 59 | Examples ('now' is replaced for the current time): | ||
435 | 60 | |||
436 | 61 | - [{'dev_id': ('now',)}] --> [{developer-id: 'dev-id', 'since': 'now'}] | ||
437 | 62 | |||
438 | 63 | - [{'dev_id': ('now', 'now')}] --> [{developer-id: 'dev-id', | ||
439 | 64 | 'since': 'now', 'until': 'now'}] | ||
440 | 65 | |||
441 | 66 | - [{'dev_id': ('2025-04-04T15:49:27.864208', | ||
442 | 67 | '2026-04-04T15:49:27.864208')}] --> | ||
443 | 68 | [{developer-id: 'dev-id', 'since': '2025-04-04T15:49:27.864208', | ||
444 | 69 | 'until': '2026-04-04T15:49:27.864208'}] | ||
445 | 70 | |||
446 | 71 | :param _developers: the list of developers to be parsed | ||
447 | 72 | """ | ||
448 | 73 | self.developers = [] | ||
449 | 74 | for dev in _developers: | ||
450 | 75 | for dev_id, time in dev.items(): | ||
451 | 76 | _dev = {'developer-id': dev_id} | ||
452 | 77 | if len(time) == 1: | ||
453 | 78 | _dev['since'] = self._parse_time(time[0]) | ||
454 | 79 | elif len(time) == 2: | ||
455 | 80 | _dev['since'] = self._parse_time(time[0]) | ||
456 | 81 | _dev['until'] = self._parse_time(time[1]) | ||
457 | 82 | self.developers.append(_dev) | ||
458 | 83 | |||
459 | 84 | @staticmethod | ||
460 | 85 | def _parse_time(time): | ||
461 | 86 | """Parse and return UTC time in ISO format | ||
462 | 87 | :param: the time to be parsed. Examples: | ||
463 | 88 | - 'now' | ||
464 | 89 | - 'datetime.datetime(2017, 4, 5, 13, 14, 9, 178673)' | ||
465 | 90 | - '2017-04-05T13:11:57' | ||
466 | 91 | """ | ||
467 | 92 | if time.lower() == 'now': | ||
468 | 93 | return datetime.utcnow().isoformat() + 'Z' | ||
469 | 94 | elif isinstance(time, datetime): | ||
470 | 95 | return time.isoformat() + 'Z' | ||
471 | 96 | elif isinstance(time, str): | ||
472 | 97 | return datetime.strptime(time, | ||
473 | 98 | "%Y-%m-%dT%H:%M:%S").isoformat() + 'Z' | ||
474 | 99 | |||
475 | 100 | |||
476 | 101 | class AssertionFactory: | ||
477 | 102 | """Factory to create assertion objects""" | ||
478 | 103 | |||
479 | 104 | mapping = {'snap-developer': SnapDeveloperAssertion} | ||
480 | 105 | |||
481 | 106 | @classmethod | ||
482 | 107 | def create(cls, _type, **kwargs): | ||
483 | 108 | """Create the assertion object | ||
484 | 109 | :param _type: assertion type. It must exist in the mapping dict | ||
485 | 110 | :param kwargs: arguments to initialize the assertion object | ||
486 | 111 | :raise RuntimeError: when assertion type does not exist in the mapping | ||
487 | 112 | """ | ||
488 | 113 | try: | ||
489 | 114 | return cls.mapping[_type](**kwargs) | ||
490 | 115 | except KeyError: | ||
491 | 116 | raise RuntimeError('Assertion type {} does not exist'.format(_type)) | ||
492 | 0 | 117 | ||
493 | === added file 'snappy_ecosystem_tests/models/key.py' | |||
494 | --- snappy_ecosystem_tests/models/key.py 1970-01-01 00:00:00 +0000 | |||
495 | +++ snappy_ecosystem_tests/models/key.py 2017-04-13 13:43:35 +0000 | |||
496 | @@ -0,0 +1,41 @@ | |||
497 | 1 | # -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- | ||
498 | 2 | |||
499 | 3 | # | ||
500 | 4 | # Snappy Ecosystem Tests | ||
501 | 5 | # Copyright (C) 2017 Canonical | ||
502 | 6 | # | ||
503 | 7 | # This program is free software: you can redistribute it and/or modify | ||
504 | 8 | # it under the terms of the GNU General Public License as published by | ||
505 | 9 | # the Free Software Foundation, either version 3 of the License, or | ||
506 | 10 | # (at your option) any later version. | ||
507 | 11 | # | ||
508 | 12 | # This program is distributed in the hope that it will be useful, | ||
509 | 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
510 | 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
511 | 15 | # GNU General Public License for more details. | ||
512 | 16 | # | ||
513 | 17 | # You should have received a copy of the GNU General Public License | ||
514 | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
515 | 19 | # | ||
516 | 20 | |||
517 | 21 | """Business model classes for User's Key""" | ||
518 | 22 | |||
519 | 23 | from textwrap import dedent | ||
520 | 24 | |||
521 | 25 | |||
522 | 26 | class Key: | ||
523 | 27 | """Represents a key entity.""" | ||
524 | 28 | |||
525 | 29 | def __init__(self, name, fingerprint, registered=False): | ||
526 | 30 | self.name = name | ||
527 | 31 | self.fingerprint = fingerprint | ||
528 | 32 | self.registered = registered | ||
529 | 33 | |||
530 | 34 | def is_registered(self): | ||
531 | 35 | """Return True if the key is registered, False otherwise""" | ||
532 | 36 | return self.registered | ||
533 | 37 | |||
534 | 38 | def __str__(self): | ||
535 | 39 | return dedent( | ||
536 | 40 | '''name: {}\npublic-key-sha3-384: {}'''.format(self.name, | ||
537 | 41 | self.fingerprint)) | ||
538 | 0 | 42 | ||
539 | === modified file 'snappy_ecosystem_tests/models/snap.py' | |||
540 | --- snappy_ecosystem_tests/models/snap.py 2017-02-21 19:06:58 +0000 | |||
541 | +++ snappy_ecosystem_tests/models/snap.py 2017-04-13 13:43:35 +0000 | |||
542 | @@ -37,3 +37,7 @@ | |||
543 | 37 | def is_private(self): | 37 | def is_private(self): |
544 | 38 | """Return True if the snap is private, False otherwise""" | 38 | """Return True if the snap is private, False otherwise""" |
545 | 39 | return self.private | 39 | return self.private |
546 | 40 | |||
547 | 41 | def get_id(self): | ||
548 | 42 | """Return the snap id""" | ||
549 | 43 | return self._id | ||
550 | 40 | 44 | ||
551 | === added file 'snappy_ecosystem_tests/tests/test_share_snap.py' | |||
552 | --- snappy_ecosystem_tests/tests/test_share_snap.py 1970-01-01 00:00:00 +0000 | |||
553 | +++ snappy_ecosystem_tests/tests/test_share_snap.py 2017-04-13 13:43:35 +0000 | |||
554 | @@ -0,0 +1,143 @@ | |||
555 | 1 | # -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- | ||
556 | 2 | |||
557 | 3 | # | ||
558 | 4 | # Snappy Ecosystem Tests | ||
559 | 5 | # Copyright (C) 2017 Canonical | ||
560 | 6 | # | ||
561 | 7 | # This program is free software: you can redistribute it and/or modify | ||
562 | 8 | # it under the terms of the GNU General Public License as published by | ||
563 | 9 | # the Free Software Foundation, either version 3 of the License, or | ||
564 | 10 | # (at your option) any later version. | ||
565 | 11 | # | ||
566 | 12 | # This program is distributed in the hope that it will be useful, | ||
567 | 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
568 | 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
569 | 15 | # GNU General Public License for more details. | ||
570 | 16 | # | ||
571 | 17 | # You should have received a copy of the GNU General Public License | ||
572 | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
573 | 19 | # | ||
574 | 20 | |||
575 | 21 | """Tests for sharing Snaps in the store""" | ||
576 | 22 | |||
577 | 23 | from testtools import skip | ||
578 | 24 | |||
579 | 25 | from snappy_ecosystem_tests.helpers.snapcraft.client import Snapcraft | ||
580 | 26 | from snappy_ecosystem_tests.helpers.snapd.snapd import Snapd | ||
581 | 27 | from snappy_ecosystem_tests.helpers.store_apis import errors | ||
582 | 28 | from snappy_ecosystem_tests.helpers.store_apis.rest_apis import Store | ||
583 | 29 | from snappy_ecosystem_tests.helpers.test_base import SnappyEcosystemTestCase | ||
584 | 30 | from snappy_ecosystem_tests.tests.commons import get_random_snap_name | ||
585 | 31 | from snappy_ecosystem_tests.utils.storeconfig import get_store_credentials | ||
586 | 32 | |||
587 | 33 | |||
588 | 34 | class ShareSnapTestCase(SnappyEcosystemTestCase): | ||
589 | 35 | |||
590 | 36 | def setUp(self): | ||
591 | 37 | super().setUp() | ||
592 | 38 | self.acls = ['package_manage', 'package_upload', 'package_access', | ||
593 | 39 | 'modify_account_key'] | ||
594 | 40 | self.snapcraft = Snapcraft() | ||
595 | 41 | self.snapd = Snapd() | ||
596 | 42 | self.store = Store() | ||
597 | 43 | email, password = get_store_credentials() | ||
598 | 44 | self.assertTrue(self.snapcraft.login(email, password)) | ||
599 | 45 | self.addCleanup(self.snapcraft.logout) | ||
600 | 46 | self.store.login(email, password, acls=self.acls) | ||
601 | 47 | self.addCleanup(self.store.logout) | ||
602 | 48 | self.assertTrue(self.snapd.login(email, password)) | ||
603 | 49 | self.addCleanup(self.snapd.logout, email) | ||
604 | 50 | |||
605 | 51 | @staticmethod | ||
606 | 52 | def _get_developer_id(index=0): | ||
607 | 53 | """Return the developer_id (aka account_id) | ||
608 | 54 | :param index: the index of the user's list that want | ||
609 | 55 | to be returned from the config stack | ||
610 | 56 | """ | ||
611 | 57 | store = Store() | ||
612 | 58 | store.login(*get_store_credentials(index=index)) | ||
613 | 59 | developer_id = store.get_account_information()['account_id'] | ||
614 | 60 | store.logout() | ||
615 | 61 | return developer_id | ||
616 | 62 | |||
617 | 63 | def _ensure_key_registered(self, user_index, name, passphrase='default'): | ||
618 | 64 | """Ensure the given key exists and is registered in the store | ||
619 | 65 | :param user_index: the index of the user you want to | ||
620 | 66 | register the key for | ||
621 | 67 | :param name: the key name | ||
622 | 68 | :param passphrase: the key passphrase | ||
623 | 69 | :return: True if key was registered successfully or if it is already | ||
624 | 70 | registered in the store, False otherwise | ||
625 | 71 | """ | ||
626 | 72 | # Create the key if it does not exist | ||
627 | 73 | self.snapd.create_key(name, passphrase) | ||
628 | 74 | # export the key | ||
629 | 75 | key_exported = self.snapd.export_key(self._get_developer_id(user_index), | ||
630 | 76 | name, passphrase) | ||
631 | 77 | # Register the key in the store | ||
632 | 78 | try: | ||
633 | 79 | self.store.register_key(key_exported) | ||
634 | 80 | except errors.StoreKeyRegistrationError as kre: | ||
635 | 81 | if 'already the current revision' in str(kre).lower(): | ||
636 | 82 | return True | ||
637 | 83 | elif 'authorization required' in str(kre).lower(): | ||
638 | 84 | user, email = get_store_credentials(user_index) | ||
639 | 85 | self.store.logout() | ||
640 | 86 | self.store.login(user, email, acls=self.acls) | ||
641 | 87 | self.store.register_key(key_exported) | ||
642 | 88 | return True | ||
643 | 89 | else: | ||
644 | 90 | return False | ||
645 | 91 | |||
646 | 92 | @skip('The assertion type "snap-developer" is still in development') | ||
647 | 93 | def test_push_and_release_a_shared_snap(self): | ||
648 | 94 | """A user to whom a snap was shared via RESTful API pushes and releases | ||
649 | 95 | a new revision and it is available on the store | ||
650 | 96 | """ | ||
651 | 97 | version = '0.1' | ||
652 | 98 | revision = 1 | ||
653 | 99 | snap_name = get_random_snap_name() | ||
654 | 100 | user_key = 'default' | ||
655 | 101 | self.assertTrue(self.store.register(snap_name), | ||
656 | 102 | 'Unable to register snap') | ||
657 | 103 | snap_id = self.store.filter_snaps(name=snap_name)[0].get_id() | ||
658 | 104 | |||
659 | 105 | # Upload the snap and wait for its processing on server to finish. | ||
660 | 106 | tracker = self.store.upload( | ||
661 | 107 | snap_name, self.snapcraft.build_and_pull(snap_name, version)) | ||
662 | 108 | tracker.track() | ||
663 | 109 | |||
664 | 110 | # Build the snap-developer assertion | ||
665 | 111 | developers = [{self._get_developer_id(index=1): ('now',)}] | ||
666 | 112 | assertion = self.snapd.build_assertion( | ||
667 | 113 | 'snap-developer', | ||
668 | 114 | publisher_id=self._get_developer_id(), | ||
669 | 115 | snap_id=snap_id, | ||
670 | 116 | developers=developers, | ||
671 | 117 | revision=revision) | ||
672 | 118 | |||
673 | 119 | # Ensure Key is registered for current user | ||
674 | 120 | self.assertTrue(self._ensure_key_registered(0, user_key)) | ||
675 | 121 | # Sign the snap-developer assertion | ||
676 | 122 | assertion_signed = self.snapd.sign_assertion(assertion, | ||
677 | 123 | key=user_key) | ||
678 | 124 | self.store.share_snap(snap_name, assertion_signed) | ||
679 | 125 | |||
680 | 126 | # Login with the user to whom the snap was shared | ||
681 | 127 | # and verify he can push and release a new version of the snap | ||
682 | 128 | new_version = '0.2' | ||
683 | 129 | new_revision = 2 | ||
684 | 130 | channels = ['candidate', 'stable'] | ||
685 | 131 | self.store.logout() | ||
686 | 132 | self.snapcraft.logout() | ||
687 | 133 | self.store.login(*get_store_credentials(index=1)) | ||
688 | 134 | self.snapcraft.login(*get_store_credentials(index=1)) | ||
689 | 135 | |||
690 | 136 | # Push and release the snap to multiple channels | ||
691 | 137 | self.snapcraft.push(self.snapcraft.build(snap_name, new_version), | ||
692 | 138 | channels=channels) | ||
693 | 139 | # Check using REST API if snap was released to all channels | ||
694 | 140 | self.assertTrue(self.store.is_snap_published(snap_name, | ||
695 | 141 | new_version, | ||
696 | 142 | new_revision, | ||
697 | 143 | channels)) | ||
698 | 0 | 144 | ||
699 | === modified file 'snappy_ecosystem_tests/utils/storeconfig.py' | |||
700 | --- snappy_ecosystem_tests/utils/storeconfig.py 2017-02-22 17:41:52 +0000 | |||
701 | +++ snappy_ecosystem_tests/utils/storeconfig.py 2017-04-13 13:43:35 +0000 | |||
702 | @@ -21,6 +21,7 @@ | |||
703 | 21 | """Get all the store related data like credentials, urls""" | 21 | """Get all the store related data like credentials, urls""" |
704 | 22 | 22 | ||
705 | 23 | import os | 23 | import os |
706 | 24 | from configparser import NoSectionError | ||
707 | 24 | 25 | ||
708 | 25 | from snappy_ecosystem_tests.commons.config import ( | 26 | from snappy_ecosystem_tests.commons.config import ( |
709 | 26 | CONFIG_STACK, | 27 | CONFIG_STACK, |
710 | @@ -31,16 +32,21 @@ | |||
711 | 31 | URL_WEB_STORE_STAGING = CONFIG_STACK.get('staging_urls', 'web') | 32 | URL_WEB_STORE_STAGING = CONFIG_STACK.get('staging_urls', 'web') |
712 | 32 | 33 | ||
713 | 33 | 34 | ||
715 | 34 | def get_store_credentials(): | 35 | def get_store_credentials(index=0): |
716 | 35 | """Get user store credentials. | 36 | """Get user store credentials. |
717 | 36 | It will retrieve the credentials stored in a user config file, | 37 | It will retrieve the credentials stored in a user config file, |
718 | 37 | if the config is not found, | 38 | if the config is not found, |
719 | 38 | then it will return the ones provided in environment variables | 39 | then it will return the ones provided in environment variables |
720 | 40 | |||
721 | 41 | :param index: the index of the user that want | ||
722 | 42 | to be returned from the user config stack | ||
723 | 39 | """ | 43 | """ |
728 | 40 | return (USER_CONFIG_STACK.get('user', 'user_email', | 44 | try: |
729 | 41 | default=os.environ.get('user_email')), | 45 | section = USER_CONFIG_STACK.get_section('store_user_{}'.format(index)) |
730 | 42 | USER_CONFIG_STACK.get('user', 'user_password', | 46 | except (NoSectionError, KeyError): |
731 | 43 | default=os.environ.get('user_password'))) | 47 | section = USER_CONFIG_STACK.get_section('user') |
732 | 48 | return (section.get('user_email') or os.environ.get('user_email'), | ||
733 | 49 | section.get('user_password') or os.environ.get('user_password')) | ||
734 | 44 | 50 | ||
735 | 45 | 51 | ||
736 | 46 | def is_staging(): | 52 | def is_staging(): |
FAILED: Continuous integration, rev:55 /platform- qa-jenkins. ubuntu. com/job/ snappy- ecosystem- tests-ci/ 349/ /platform- qa-jenkins. ubuntu. com/job/ generic- update- mp/2338/ console
https:/
Executed test runs:
None: https:/
Click here to trigger a rebuild: /platform- qa-jenkins. ubuntu. com/job/ snappy- ecosystem- tests-ci/ 349/rebuild
https:/