Merge ~twom/snapstore-client:add-upload-authentication into snapstore-client:master
- Git
- lp:~twom/snapstore-client
- add-upload-authentication
- Merge into master
Proposed by
Tom Wardill
Status: | Merged |
---|---|
Approved by: | Tom Wardill |
Approved revision: | 00476d8bc59f05203b169e8793855c1731a67ad1 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~twom/snapstore-client:add-upload-authentication |
Merge into: | snapstore-client:master |
Diff against target: |
1255 lines (+947/-45) 12 files modified
README (+23/-0) snapstore (+36/-0) snapstore_client/logic/login.py (+7/-0) snapstore_client/logic/overrides.py (+27/-32) snapstore_client/logic/push.py (+306/-0) snapstore_client/logic/tests/test-snap-assert.assert (+101/-0) snapstore_client/logic/tests/test-snap-map.json (+73/-0) snapstore_client/logic/tests/test_login.py (+3/-1) snapstore_client/logic/tests/test_overrides.py (+152/-8) snapstore_client/logic/tests/test_push.py (+169/-0) snapstore_client/utils.py (+26/-0) snapstore_client/webservices.py (+24/-4) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tony Simpson (community) | Approve | ||
Review via email: mp+364917@code.launchpad.net |
Commit message
Add control and upload for an offline proxy
Description of the change
Offline proxies can't issue or validate macaroons, so use HTTP Basic Auth.
Also add uploading of snaps via downloaded .snap, .assert and channel map metadata. This code mostly ported from the cli of the snap-store-proxy.
Relies on the upload-
To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) : | # |
- 12c75da... by Tom Wardill
-
Use env password as default
- dd76e1e... by Tom Wardill
-
Lint error
Revision history for this message
Tom Wardill (twom) : | # |
- 0d85442... by Tom Wardill
-
Add basic README instructions
- 935fb22... by Tom Wardill
-
Rename upload to push
- 00476d8... by Tom Wardill
-
Extract a tar file before upload
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/README b/README |
2 | index 6171594..ab1584e 100644 |
3 | --- a/README |
4 | +++ b/README |
5 | @@ -6,3 +6,26 @@ This repo contains a client to adminster a snapstore. |
6 | After authenticating with a snapstore the administrator can: |
7 | |
8 | - manage revision overrides |
9 | + |
10 | +Building |
11 | +-------- |
12 | + |
13 | +You may wish to perform these installations inside a LXD container. |
14 | + |
15 | +Install the dependencies:: |
16 | + |
17 | + cat dependencies | xargs sudo apt install -y |
18 | + |
19 | +Create the environment |
20 | +---------------------- |
21 | + |
22 | +Use the Makefile:: |
23 | + |
24 | + make bootstrap |
25 | + |
26 | +Running for development |
27 | +----------------------- |
28 | + |
29 | +The virtualenv environment is setup in /tmp:: |
30 | + |
31 | + /tmp/snapstore-client.tmp/env/bin/python snapstore |
32 | diff --git a/snapstore b/snapstore |
33 | index a21a8a1..cd67b71 100755 |
34 | --- a/snapstore |
35 | +++ b/snapstore |
36 | @@ -7,6 +7,7 @@ |
37 | |
38 | import argparse |
39 | import logging |
40 | +import os |
41 | import sys |
42 | |
43 | from snapstore_client.cli import configure_logging |
44 | @@ -16,6 +17,7 @@ from snapstore_client.logic.overrides import ( |
45 | list_overrides, |
46 | override, |
47 | ) |
48 | +from snapstore_client.logic.push import push_snap |
49 | |
50 | |
51 | DEFAULT_SSO_URL = 'https://login.ubuntu.com/' |
52 | @@ -44,6 +46,8 @@ def parse_args(): |
53 | login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?') |
54 | login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL', |
55 | default=DEFAULT_SSO_URL) |
56 | + login_parser.add_argument('--offline', help="Use offline mode interaction", |
57 | + action='store_true') |
58 | login_parser.set_defaults(func=login) |
59 | |
60 | list_overrides_parser = subparsers.add_parser( |
61 | @@ -55,6 +59,11 @@ def parse_args(): |
62 | list_overrides_parser.add_argument( |
63 | 'snap_name', |
64 | help='The name of the snap whose channel map should be listed.') |
65 | + list_overrides_parser.add_argument( |
66 | + '--password', |
67 | + help='Password for interacting with an offline proxy', |
68 | + default=os.environ.get('SNAP_PROXY_PASSWORD') |
69 | + ) |
70 | list_overrides_parser.set_defaults(func=list_overrides) |
71 | |
72 | override_parser = subparsers.add_parser( |
73 | @@ -69,6 +78,11 @@ def parse_args(): |
74 | override_parser.add_argument( |
75 | 'channel_map_entries', nargs='+', metavar='channel_map_entry', |
76 | help='A channel map override, in the form <channel>=<revision>.') |
77 | + override_parser.add_argument( |
78 | + '--password', |
79 | + help='Password for interacting with an offline proxy', |
80 | + default=os.environ.get('SNAP_PROXY_PASSWORD') |
81 | + ) |
82 | override_parser.set_defaults(func=override) |
83 | |
84 | delete_override_parser = subparsers.add_parser( |
85 | @@ -83,8 +97,30 @@ def parse_args(): |
86 | delete_override_parser.add_argument( |
87 | 'channels', nargs='+', metavar='channel', |
88 | help='A channel whose overrides should be deleted.') |
89 | + delete_override_parser.add_argument( |
90 | + '--password', |
91 | + help='Password for interacting with an offline proxy', |
92 | + default=os.environ.get('SNAP_PROXY_PASSWORD') |
93 | + ) |
94 | delete_override_parser.set_defaults(func=delete_override) |
95 | |
96 | + push_snap_parser = subparsers.add_parser( |
97 | + 'push-snap', help='push a snap to an offline proxy') |
98 | + push_snap_parser.add_argument( |
99 | + 'snap_tar_file', |
100 | + help='The .tar.gz file of a bundled downloaded snap') |
101 | + push_snap_parser.add_argument( |
102 | + '--push-channel-map', |
103 | + action='store_true', |
104 | + help="Force push of the channel map," |
105 | + " removing any existing overrides") |
106 | + push_snap_parser.add_argument( |
107 | + '--password', |
108 | + help='Password for interacting with an offline proxy', |
109 | + default=os.environ.get('SNAP_PROXY_PASSWORD') |
110 | + ) |
111 | + push_snap_parser.set_defaults(func=push_snap) |
112 | + |
113 | if len(sys.argv) == 1: |
114 | # Display help if no arguments are provided. |
115 | parser.print_help() |
116 | diff --git a/snapstore_client/logic/login.py b/snapstore_client/logic/login.py |
117 | index 7d3a313..9550988 100644 |
118 | --- a/snapstore_client/logic/login.py |
119 | +++ b/snapstore_client/logic/login.py |
120 | @@ -31,6 +31,13 @@ def login(args): |
121 | gw_url = args.store_url |
122 | sso_url = args.sso_url |
123 | |
124 | + if args.offline: |
125 | + cfg = config.Config() |
126 | + store = cfg.store_section('default') |
127 | + store.set('gw_url', gw_url) |
128 | + cfg.save() |
129 | + return |
130 | + |
131 | logger.info('Enter your Ubuntu One SSO credentials.') |
132 | email = args.email |
133 | if not email: |
134 | diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py |
135 | index 667bd0a..d3c8356 100644 |
136 | --- a/snapstore_client/logic/overrides.py |
137 | +++ b/snapstore_client/logic/overrides.py |
138 | @@ -13,34 +13,16 @@ from snapstore_client.presentation_helpers import ( |
139 | channel_map_string_to_tuple, |
140 | override_to_string, |
141 | ) |
142 | +from snapstore_client.utils import ( |
143 | + _check_default_store, |
144 | + _log_authorized_error, |
145 | + _log_credentials_error, |
146 | +) |
147 | |
148 | |
149 | logger = logging.getLogger(__name__) |
150 | |
151 | |
152 | -def _log_credentials_error(e): |
153 | - logger.error('%s', e) |
154 | - logger.error('Try to "snap-store-proxy-client login" again.') |
155 | - |
156 | - |
157 | -def _log_authorized_error(): |
158 | - logger.error(("Perhaps you have not been registered as an " |
159 | - "admin with the proxy.")) |
160 | - logger.error("Try 'snap-proxy add-admin' on the proxy host.") |
161 | - |
162 | - |
163 | -def _check_default_store(cfg): |
164 | - """Load the default store from the config.""" |
165 | - store = cfg.store_section('default') |
166 | - # If the gw URL is configured then everything else should be too. |
167 | - if not store.get('gw_url'): |
168 | - logger.error( |
169 | - 'No store configuration found. ' |
170 | - 'Have you run "snap-store-proxy-client login"?') |
171 | - return None |
172 | - return store |
173 | - |
174 | - |
175 | def list_overrides(args): |
176 | cfg = config.Config() |
177 | store = _check_default_store(cfg) |
178 | @@ -48,9 +30,14 @@ def list_overrides(args): |
179 | return 1 |
180 | |
181 | try: |
182 | - response = ws.refresh_if_necessary( |
183 | - store, ws.get_overrides, |
184 | - store, args.snap_name, series=args.series) |
185 | + if args.password: |
186 | + response = ws.get_overrides( |
187 | + store, args.snap_name, |
188 | + series=args.series, password=args.password) |
189 | + else: |
190 | + response = ws.refresh_if_necessary( |
191 | + store, ws.get_overrides, |
192 | + store, args.snap_name, series=args.series) |
193 | except exceptions.InvalidCredentials as e: |
194 | _log_credentials_error(e) |
195 | return 1 |
196 | @@ -77,9 +64,13 @@ def override(args): |
197 | 'series': args.series, |
198 | }) |
199 | try: |
200 | - response = ws.refresh_if_necessary( |
201 | - store, ws.set_overrides, |
202 | - store, overrides) |
203 | + if args.password: |
204 | + response = ws.set_overrides( |
205 | + store, overrides, password=args.password) |
206 | + else: |
207 | + response = ws.refresh_if_necessary( |
208 | + store, ws.set_overrides, |
209 | + store, overrides) |
210 | except exceptions.InvalidCredentials as e: |
211 | _log_credentials_error(e) |
212 | return 1 |
213 | @@ -105,9 +96,13 @@ def delete_override(args): |
214 | 'series': args.series, |
215 | }) |
216 | try: |
217 | - response = ws.refresh_if_necessary( |
218 | - store, ws.set_overrides, |
219 | - store, overrides) |
220 | + if args.password: |
221 | + response = ws.set_overrides( |
222 | + store, overrides, password=args.password) |
223 | + else: |
224 | + response = ws.refresh_if_necessary( |
225 | + store, ws.set_overrides, |
226 | + store, overrides) |
227 | except exceptions.InvalidCredentials as e: |
228 | _log_credentials_error(e) |
229 | return 1 |
230 | diff --git a/snapstore_client/logic/push.py b/snapstore_client/logic/push.py |
231 | new file mode 100644 |
232 | index 0000000..abd06e8 |
233 | --- /dev/null |
234 | +++ b/snapstore_client/logic/push.py |
235 | @@ -0,0 +1,306 @@ |
236 | +import json |
237 | +import logging |
238 | +from pathlib import Path |
239 | +import tarfile |
240 | +import tempfile |
241 | +from urllib.parse import urlsplit, urlunsplit |
242 | + |
243 | +import requests |
244 | +from requests.exceptions import HTTPError |
245 | + |
246 | +from snapstore_client import ( |
247 | + config, |
248 | + exceptions, |
249 | +) |
250 | +from snapstore_client.utils import ( |
251 | + _check_default_store, |
252 | + _log_authorized_error, |
253 | + _log_credentials_error, |
254 | +) |
255 | + |
256 | +logger = logging.getLogger(__name__) |
257 | + |
258 | + |
259 | +# XXX twom 2019-03-19 hardcoded for now, awaiting RBAC integration |
260 | +USERNAME = 'admin' |
261 | + |
262 | + |
263 | +class pushException(Exception): |
264 | + pass |
265 | + |
266 | + |
267 | +class ChannelMapExistsException(Exception): |
268 | + pass |
269 | + |
270 | + |
271 | +def _push_ident(store, password, downloaded_map): |
272 | + """push the snap details to snapident""" |
273 | + |
274 | + snap_id = downloaded_map['snap-id'] |
275 | + snap_details = downloaded_map['snap'] |
276 | + push_details = { |
277 | + 'snap_id': snap_id, |
278 | + 'snap_name': snap_details['name'], |
279 | + 'private': False, # If you're pushing to your own proxy |
280 | + 'stores': ['ubuntu'], # ditto |
281 | + 'blob': snap_details, |
282 | + 'publisher_id': snap_details['publisher']['id'], |
283 | + 'publisher_name': snap_details['publisher']['display-name'], |
284 | + 'publisher_title': snap_details['publisher']['username'], |
285 | + 'publisher_validation': snap_details['publisher']['validation'], |
286 | + 'license': snap_details['license'], |
287 | + 'prices': snap_details['prices'], |
288 | + 'summary': snap_details['summary'], |
289 | + 'title': snap_details['title'], |
290 | + } |
291 | + |
292 | + url = store.get('gw_url') |
293 | + |
294 | + response = requests.post( |
295 | + url + '/snaps/update', |
296 | + json={'snaps': [push_details]}, |
297 | + auth=requests.auth.HTTPBasicAuth(USERNAME, password) |
298 | + ) |
299 | + if response.status_code != 200: |
300 | + raise pushException( |
301 | + "Failure in pushing to snapident: {}".format( |
302 | + response.content)) |
303 | + |
304 | + |
305 | +def _push_revs(store, password, downloaded_map): |
306 | + """push the revision information to snaprevs""" |
307 | + store_url = store.get('gw_url') |
308 | + |
309 | + snap_id = downloaded_map['snap-id'] |
310 | + for instance in downloaded_map['channel-map']: |
311 | + # rewrite the download_url |
312 | + download_url = instance['download']['url'] |
313 | + |
314 | + url = urlsplit(download_url) |
315 | + fqdn = urlsplit(store_url) |
316 | + # Rewrite scheme and location |
317 | + download_url = urlunsplit(url._replace(scheme=fqdn[0], netloc=fqdn[1])) |
318 | + |
319 | + # create the revision |
320 | + rev = { |
321 | + 'snap_id': snap_id, |
322 | + 'revision': instance['revision'], |
323 | + 'version': instance['version'], |
324 | + 'confinement': instance['confinement'], |
325 | + 'architectures': instance['architectures'], |
326 | + 'created_at': instance['created-at'], |
327 | + # This is required, but isn't available to retrieve from /info |
328 | + 'created_by': '', |
329 | + 'binary_path': download_url, |
330 | + 'binary_filesize': instance['download']['size'], |
331 | + 'binary_sha3_384': instance['download']['sha3-384'], |
332 | + 'snap_yaml': instance.get('snap-yaml', ''), |
333 | + 'epoch': instance['epoch'], |
334 | + 'type': instance['type'], |
335 | + 'base': instance['base'], |
336 | + 'common_ids': instance['common-ids'], |
337 | + } |
338 | + revs_response = requests.post( |
339 | + store_url + '/revisions/create', |
340 | + json=[rev], |
341 | + auth=requests.auth.HTTPBasicAuth(USERNAME, password) |
342 | + ) |
343 | + # 409 / Conflict - already exists |
344 | + if revs_response.status_code not in [201, 409]: |
345 | + raise pushException( |
346 | + "Failure to push revisions to snaprevs: {}".format( |
347 | + revs_response.content)) |
348 | + |
349 | + |
350 | +def _push_channelmap(store, password, downloaded_map, force_push=False): |
351 | + """push the channel map to snaprevs""" |
352 | + store_url = store.get('gw_url') |
353 | + |
354 | + snap_id = downloaded_map['snap-id'] |
355 | + |
356 | + # Check if we have an existing channel map |
357 | + existing = requests.post( |
358 | + store_url + '/channelmaps/filter', |
359 | + json={'filters': [{'series': '16', 'snap_id': snap_id}]}, |
360 | + auth=requests.auth.HTTPBasicAuth(USERNAME, password)) |
361 | + if existing.status_code != 200: |
362 | + raise pushException("Error retrieving current channelmap") |
363 | + if existing.json().get('channelmaps') and not force_push: |
364 | + raise ChannelMapExistsException("Channel map already exists.") |
365 | + |
366 | + for instance in downloaded_map['channel-map']: |
367 | + if instance['channel']['track'] == 'latest': |
368 | + track = None |
369 | + else: |
370 | + track = instance['channel']['track'] |
371 | + chan_map = { |
372 | + # This is wrong, it should be the current developer id |
373 | + # but the pusher might not be a developer |
374 | + # XXX (twom): can we get this from upstream? |
375 | + 'developer_id': downloaded_map['snap']['publisher']['id'], |
376 | + 'release_requests': [ |
377 | + { |
378 | + 'snap_id': snap_id, |
379 | + 'channel': [ |
380 | + track, |
381 | + instance['channel']['risk'], |
382 | + None, # We can't deal in 'branch', it's not on the API |
383 | + ], |
384 | + 'architecture': instance['channel']['architecture'], |
385 | + 'series': '16', |
386 | + 'revision': instance['revision'] |
387 | + } |
388 | + ] |
389 | + } |
390 | + map_response = requests.post( |
391 | + store_url + '/channelmaps/update', |
392 | + json=chan_map, |
393 | + auth=requests.auth.HTTPBasicAuth(USERNAME, password) |
394 | + ) |
395 | + if map_response.status_code != 200: |
396 | + raise pushException("Error pushing channel map") |
397 | + |
398 | + |
399 | +def _load_assertion_file(assert_path): |
400 | + with open(str(assert_path)) as assert_file: |
401 | + raw_contents = assert_file.read().splitlines() |
402 | + return raw_contents |
403 | + |
404 | + |
405 | +def _split_assertions(raw_contents): |
406 | + # split the file into separate asserts |
407 | + available_asserts = [] |
408 | + current_assert = [] |
409 | + for line in raw_contents: |
410 | + # if we've encountered a new assert, save the old one, start a new one |
411 | + if line.startswith('type: ') and current_assert: |
412 | + available_asserts.append(current_assert) |
413 | + current_assert = [] |
414 | + current_assert.append(line) |
415 | + # account for the last one in the file |
416 | + available_asserts.append(current_assert) |
417 | + |
418 | + # Because we've split the file, we've ended up with the newlines |
419 | + # from between the concatenated asserts. |
420 | + # Remove it from the ones that have it so they're clean to push |
421 | + for assertion in available_asserts: |
422 | + assertion[:] = assertion[:-1] if assertion[-1] == '' else assertion |
423 | + return available_asserts |
424 | + |
425 | + |
426 | +def _add_assertion_to_service(store, password, list_assertions): |
427 | + store_url = store.get('gw_url') |
428 | + |
429 | + # push the split asserts |
430 | + for assertion in list_assertions: |
431 | + # get the type |
432 | + assert_type = assertion[0].split('type: ')[1] |
433 | + # ignore the account-key of canonical, it's already a trusted assert |
434 | + # and will error on push |
435 | + canonical_key = 'account-id: canonical' |
436 | + if (assert_type == 'account-key' and canonical_key in assertion): |
437 | + continue |
438 | + if (assert_type == 'account' and canonical_key in assertion): |
439 | + continue |
440 | + response = requests.post( |
441 | + store_url + '/v1/assertions', |
442 | + '\n'.join(assertion), |
443 | + headers={'Content-Type': 'application/x.ubuntu.assertion'}, |
444 | + auth=requests.auth.HTTPBasicAuth(USERNAME, password)) |
445 | + if response.status_code != 201: |
446 | + raise pushException( |
447 | + "Failed to push: {} - {}".format( |
448 | + response.status_code, response.content)) |
449 | + |
450 | + |
451 | +def _push_assertions(store, password, assert_path): |
452 | + """Split a .assert and push individual assertions to snapassert""" |
453 | + |
454 | + # load the file |
455 | + raw_contents = _load_assertion_file(assert_path) |
456 | + # split the assert file into single assertions for push |
457 | + current_asserts = _split_assertions(raw_contents) |
458 | + # push the assertions to the internal snap-assertion-service |
459 | + try: |
460 | + _add_assertion_to_service(store, password, current_asserts) |
461 | + except pushException: |
462 | + raise pushException("Assertion file: {}".format(assert_path)) |
463 | + |
464 | + |
465 | +def _push_file_to_nginx_cache(store, password, snap_path, snap_id, revision): |
466 | + """Add the file to the nginx cache via the snapproxy service""" |
467 | + store_url = store.get('gw_url') |
468 | + target_filename = '{}_{}.snap'.format(snap_id, revision) |
469 | + files = {target_filename: open(snap_path, 'rb')} |
470 | + response = requests.post( |
471 | + store_url + '/files/upload', |
472 | + files=files, |
473 | + auth=requests.auth.HTTPBasicAuth(USERNAME, password)) |
474 | + if response.status_code != 200: |
475 | + print(response.status_code) |
476 | + raise pushException("Failed to push file to proxy cache") |
477 | + |
478 | + |
479 | +def _push(store, tar_file, password, force_channel_map=False): |
480 | + """ |
481 | + push a given .snap with matching .assert and .json |
482 | + to an offline proxy |
483 | + """ |
484 | + extract_dir = Path(tempfile.mkdtemp()) |
485 | + with tarfile.open(str(tar_file), 'r:gz') as tar: |
486 | + tar.extractall(str(extract_dir)) |
487 | + |
488 | + json_file = extract_dir / 'channel-map.json' |
489 | + json_path = Path(json_file) |
490 | + if not json_path.exists(): |
491 | + logger.error("Snap information file not found at {}".format(json_path)) |
492 | + return |
493 | + |
494 | + with json_path.open() as fh: |
495 | + downloaded_map = json.load(fh) |
496 | + |
497 | + _push_ident(store, password, downloaded_map) |
498 | + _push_revs(store, password, downloaded_map) |
499 | + try: |
500 | + _push_channelmap( |
501 | + store, password, downloaded_map, force_channel_map) |
502 | + except ChannelMapExistsException as e: |
503 | + logger.info(str(e)) |
504 | + logger.info( |
505 | + "Not updating the channel map, either manage revisions using " |
506 | + "`snap-proxy override` or try again with `--push-channel-map`") |
507 | + |
508 | + for instance in downloaded_map['channel-map']: |
509 | + snap_name = downloaded_map['snap']['name'] |
510 | + snap_file_name = '{}_{}.snap'.format( |
511 | + snap_name, instance['revision']) |
512 | + assert_file_name = '{}_{}.assert'.format( |
513 | + snap_name, instance['revision']) |
514 | + snap_path = str(json_path.parent / snap_file_name) |
515 | + assert_path = str(json_path.parent / assert_file_name) |
516 | + _push_file_to_nginx_cache( |
517 | + store, password, snap_path, |
518 | + downloaded_map['snap-id'], instance['revision']) |
519 | + _push_assertions(store, password, assert_path) |
520 | + |
521 | + |
522 | +def push_snap(args): |
523 | + cfg = config.Config() |
524 | + store = _check_default_store(cfg) |
525 | + if not store: |
526 | + return 1 |
527 | + |
528 | + try: |
529 | + if args.password: |
530 | + _push(store, args.snap_tar_file, |
531 | + args.password, args.push_channel_map) |
532 | + else: |
533 | + logger.error( |
534 | + "This command only works with a supplied password" |
535 | + " and a proxy in offline mode") |
536 | + except exceptions.InvalidCredentials as e: |
537 | + _log_credentials_error(e) |
538 | + return 1 |
539 | + except HTTPError: |
540 | + _log_authorized_error() |
541 | + return 1 |
542 | diff --git a/snapstore_client/logic/tests/test-snap-assert.assert b/snapstore_client/logic/tests/test-snap-assert.assert |
543 | new file mode 100644 |
544 | index 0000000..b6b2ebd |
545 | --- /dev/null |
546 | +++ b/snapstore_client/logic/tests/test-snap-assert.assert |
547 | @@ -0,0 +1,101 @@ |
548 | +type: account-key |
549 | +authority-id: canonical |
550 | +revision: 2 |
551 | +public-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul |
552 | +account-id: canonical |
553 | +name: store |
554 | +since: 2016-04-01T00:00:00.0Z |
555 | +body-length: 717 |
556 | +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk |
557 | + |
558 | +AcbBTQRWhcGAARAA0KKYYQWuHOrsFVi4p4l7ZzSvX7kLgJFFeFgOkzdWKBTHEnsMKjl5mefFe9ji |
559 | +qe8NlmJdfY7BenP7XeBtwKp700H/t9lLrZbpTNAPHXYxEWFJp5bPqIcJYBZ+29oLVLN1Tc5X482R |
560 | +vCiDqL8+pPYqBrK2fNlyPlNNSum9wI70rDDL4r6FVvr+osTnGejibdV8JphWX+lrSQDnRSdM8KJi |
561 | +UM43vTgLGTi9W54oRhsA2OFexRfRksTrnqGoonCjqX5wO3OFSaMDzMsO2MJ/hPfLgDqw53qjzuKL |
562 | +Iec9OL3k5basvu2cj5u9tKwVFDsCKK2GbKUsWWpx2KTpOifmhmiAbzkTHbH9KaoMS7p0kJwhTQGA |
563 | +o9aJ9VMTWHJc/NCBx7eu451u6d46sBPCXS/OMUh2766fQmoRtO1OwCTxsRKG2kkjbMn54UdFULl9 |
564 | +VfzvyghMNRKIezsEkmM8wueTqGUGZWa6CEZqZKwhe/PROxOPYzqtDH18XZknbU1n5lNb7vNfem9F |
565 | +2ai+3+JyFnW9UhfvpVF7gzAgdyCqNli4C6BIN43uwoS8HkykocZS/+Gv52aUQ/NZ8BKOHLw+7ant |
566 | +Q0o8W9ltSLZbEMxFIPSN0stiZlkXAp6DLyvh1Y4wXSynDjUondTpej2fSvSlCz/W5v5V7qA4nIcG |
567 | +vUvV7RjVzv17ut0AEQEAAQ== |
568 | + |
569 | +AcLDXAQAAQoABgUCV83k9QAKCRDUpVvql9g3IBT8IACKZ7XpiBZ3W4lqbPssY6On81WmxQLtvsMV |
570 | +WTp6zZpl/wWOSt2vMNUk9pvcmrNq1jG9CuhDfWFLGXEjcrrmVkN3YuCOajMSPFCGrxsIBLSRt/bP |
571 | +nrKykdLAAzMfG8rP1d82bjFFiIieE+urQ0Kcv09Jtdvavq3JT1Tek5mFyyfhHNlQEKOzWqmRWiLg |
572 | +3c3VOZUs1ZD8TSlnuq/x+5T0X0YtOyGjSlVxk7UybbyMNd6MZfNaMpIG4x+mxD3KHFtBAC7O6kLe |
573 | +eX3i6j5nCY5UABfA3DZEAkWP4zlmdBEOvZ9t293NaDdOpzsUHRkoi0Zez/9BHQ/kwx/uNc2WqrYm |
574 | +inCmu16JGNeXqsyinnLl7Ghn2RwhvDMlLxF6RTx8xdx1yk6p3PBTwhZMUvuZGjUtN/AG8BmVJQ19 |
575 | +rsGSRkkSywvnhVJRB2sudnrMBmNS2goJbzSbmJnOlBrd2WsV0T9SgNMWZBiov3LvU4o2SmAb6b+k |
576 | +rYwh8H5QHcuuYJuxDjFhPswIp6Wes5T6hUicf3SWtObcDS4HSkVS4ImBjjX9YgCuFy7QdnooOWEY |
577 | +aPvkRw3XCVeYq0K6w9GRsk1YFErD4XmXXZjDYY650MX9v42Sz5MmphHV8jdIY5ssbadwFSe2rCQI |
578 | +6UX08zy7RsIb19hTndE6ncvSNDChUR9eEnCm73eYaWTWTnq1cxdVP/s52r8uss++OYOkPWqh5nOu |
579 | +haRn7INjH/yZX4qXjNXlTjo0PnHH0q08vNKDwLhxS+D9du+70FeacXFyLIbcWllSbJ7DmbumGpFo |
580 | +yYbtj3FDDPzachFQdIG3lSt+cSUGeyfSs6wVtc3cIPka/2Urx7RprfmoWSI6+a5NcLdj0u2z8O96 |
581 | +HxeIgxDpg/3gT8ZIuFKePMcLDM19Fh/p0ysCsX+84B9chNWtsMSmIaE57V+959MVtsLu7SLb9gi7 |
582 | +skrju0pQCwsu2wHMLTNd1f3PTHmrr49hxetTus07HSQUApMtAGKzQilF5zqFjbyaTd4xgQbd+PKW |
583 | +CjFyzQTDOcUhXpuUGt/IzlqiFfsCsmbj2K4KdSNYMlqIgZ3Azu8KvZLIhsyN7v5vNIZSPfEbjdeu |
584 | +ClU9r0VRiJmtYBUjcSghD9LWn+yRLwOxhfQVjm0cBwIt5R/yPF/qC76yIVuWUtM5Y2/zJR1J8OFq |
585 | +qWchvlImHtvDzS9FQeLyzJAOjvZ2CnWp2gILgUz0WQdOk1Dq8ax7KS9BQ42zxw9EZAEPw3PEFqRy |
586 | +IQsRTONp+iVS8YxSmoYZjDlCgRMWUmawez/Fv5b9Fb/XkO5Eq4e+KfrpUujXItaipb+tV8h5v3tr |
587 | +oG3Ie3WOHrVjCLXIdYslpL1O4nadqR6Xv58pHj6k |
588 | + |
589 | +type: account |
590 | +authority-id: canonical |
591 | +account-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX |
592 | +display-name: Tom Wardill |
593 | +timestamp: 2018-02-06T09:22:49.118291Z |
594 | +username: twom |
595 | +validation: unproven |
596 | +sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul |
597 | + |
598 | +AcLBUgQAAQoABgUCWnlz6QAA40sQALjt4pGWbG9icFARLbNSnx0ldc9cEytJ5ctLVvG0x3P7hP3u |
599 | +6VntHcurt5mkQmW9nTJ8CY3R5uAPVkVda1gFabFlIqZPl4fAv0KqH9vziqSsc1rn05nyLcPMEu2P |
600 | +WmWsOh/F7O3IqJrvdX1Xpuv0BsDjZi9merN20BHcY8LDVPd5qUDyN/BISbNzogzHC3MdilYAP75p |
601 | +YoYYhH7dHruLgPREOKEhAjIoFguvPtML1xAUFz5XDpvm0sHDURuDYN/cwpFOLsOkPCDokyYC/Udb |
602 | +Bh9foT4o1FYvBQOi2DjFBs0UFgAk/2ud8Rv9N10O2MnsQ0ZGerAsGAVIARkz5zjSHZPFokL5PJQ/ |
603 | +AzJBVRR2NPtRvBEnF/bG0xR5JR/5AZK2K7mIKrQt22CFL4M4iL7uQI1Q1i7PBpysYT7vZwyyFkEP |
604 | +M2NXmvpZ34i+5JnyEtdn2H+JVhvQS1RpqdwDwfpqn+lYkJBvDN8B9aDtyp+yMdDTLoLt74JZKP4V |
605 | +YeptgPiaMUHOQJx6gdp/Fa9FJAQiNUFjXe9/lfF1yebu0CVXdO6CaSRGBXrQfk2E/WpZNsmZOTq+ |
606 | +Rc0dGtlMeulEo8fY9RcJlsKe7hjlzX1Yk7JFkGqb60Fzqy0AzoJwDXjTnQPo0AKyNz+ryqly3Sur |
607 | +c58OOgdNDw9KbjYoVXvjFLzAyA/k |
608 | + |
609 | +type: snap-declaration |
610 | +authority-id: canonical |
611 | +series: 16 |
612 | +snap-id: p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM |
613 | +publisher-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX |
614 | +snap-name: gif-cli |
615 | +timestamp: 2018-10-02T15:43:27.762767Z |
616 | +sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul |
617 | + |
618 | +AcLBUgQAAQoABgUCW7OSHwAAV/cQAB9wlV5pNd/+20BSCv6tsBAUugrHbFRVjIMHsYoj7Can0mSB |
619 | +9vRSbDnFB91VWk+6gBpR+xXtHJithiyCl3fitf2tNuRKm/W6M4fhdctQXX8pcPSsonfSq1+1apgT |
620 | +vy7BZaChVEMROY3y5/NZNj4x/+1t38cA4dqxOHIXbhc1B/SifKpFQWq8hu+GMSFFVSxWCJp7rLor |
621 | +sqfBFUY1cFeCGsW/iawAPTXk3z7tZhkhoi1FQeu9KrQGMCY0aaD1uZhPLfCwP4mDmsuW3lxJ5QIt |
622 | +fhQdO46jh6Ea9nahjAMWrIYPL6G1Yk5aFH6cQ7LfcQq/JHEcDvFUXbtM5jQJU4gPcjnSkFlRCSNA |
623 | +aig1trd/RjqQyVMiIBDGRvOPZZij7AdnQ+P88CH1k5oWppNTgk4VM0UsSCdFFXVw7sQlPUWjRaYP |
624 | +PHfy1xkMZBw11jLDytOVwLKjPIZs8dCTHKkPfAD4oTP0+W/OvOBerUoHGeaxlZ8Cx17wFcfllEcO |
625 | +fiYmnYOAd0JRIXeOXH9/LTbSnb9dMPaaL9jIz0YhmNJOPuzUPpfGeoEQMlt61OfTtEuvjDAq+Nh/ |
626 | +mhCDl0RnvH+8rjLv0ksYLETsFCkKlOYYMugoj0ruBjl3ss6GU/36RFX9quKEdDyyyus71C1V6ohF |
627 | +VzUiaij6jfTkWC4rMPyl69L3Z7fw |
628 | + |
629 | +type: snap-revision |
630 | +authority-id: canonical |
631 | +snap-sha3-384: MrP3xzTBH8SQLC1oOfMNIigeJ_0GUE1yqugKZzx2dKdqO2fHbzYa2SfJtxS5JWMe |
632 | +developer-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX |
633 | +snap-id: p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM |
634 | +snap-revision: 3 |
635 | +snap-size: 9678848 |
636 | +timestamp: 2018-10-14T20:48:18.733551Z |
637 | +sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul |
638 | + |
639 | +AcLBUgQAAQoABgUCW8OrkgAAm/oQAAnCsfsbDBJt59mPG9A1BRHYZHV7EqYis75QxZPzMLlkpoUG |
640 | +Xqc8g0aHgEt+STwrrAqUli9G5Yg3Tk8rWb7CV7MlB3c/pBgnW8XLWm3zp1S4ZonDPpKYmgeBcmui |
641 | +hH9lXPlHHnL9dGbObhPk7jbAFHOmqgC9S5lD6Uuj7RTn8Hv+hT7YbvZ8LpjcRfLfUJAIhfCCcxHm |
642 | +euwRMyGP5NFwOLWIj4XZNKhKf6Tj8bUJV9Kcf7MBZ/cvdwzPiTkU5EleHid4bdZcUkrcNTt8wFI4 |
643 | +NxU+CH8m3mzP2/EF+amxoqHc5+9qMYWtu2iou1E8oY5M7s+QujTHPipzgPQkkMLSocyoO1ntezd1 |
644 | +YhUu2GkXw2IRmmgVvaMjmXcE+voGNsT+W2CeHqQNvZvip3KDKvQG3G8tX9Sf6V7NSjqn1+IwpJpS |
645 | +Bx61HphfypyHbho7KgYcSYhdqCQF8XiLwQ3pWtE6l654I3kHzuzyrYKR+Ts1byHB+w5sLx4fw7xV |
646 | +7/CbvzO6konCrW00bG3UGKHZk4zJGBlv5cfGQxuq5GNaaDWZ9Pg5awSrgB8kShAGzOpdABle8eYg |
647 | +RVUbPo53cZO3ditJ+B7VT1pA0on1BiyQGKQUQplr+glZtplPsu6q+A0HzLZ3Wg7EFUdrSThhDh6N |
648 | +46jdhxSAwRSPVHtzHm/fgUcGXuMJ |
649 | diff --git a/snapstore_client/logic/tests/test-snap-map.json b/snapstore_client/logic/tests/test-snap-map.json |
650 | new file mode 100644 |
651 | index 0000000..0b28b16 |
652 | --- /dev/null |
653 | +++ b/snapstore_client/logic/tests/test-snap-map.json |
654 | @@ -0,0 +1,73 @@ |
655 | +{ |
656 | + "channel-map": [{ |
657 | + "architectures": ["amd64"], |
658 | + "base": "core18", |
659 | + "channel": { |
660 | + "architecture": "amd64", |
661 | + "name": "stable", |
662 | + "released-at": "2018-11-08T18:30:43.652281+00:00", |
663 | + "risk": "stable", |
664 | + "track": "latest" |
665 | + }, |
666 | + "common-ids": [], |
667 | + "confinement": "strict", |
668 | + "created-at": "2018-10-14T20:48:12.808884+00:00", |
669 | + "download": { |
670 | + "deltas": [], |
671 | + "sha3-384": "32b3f7c734c11fc4902c2d6839f30d22281e27fd06504d72aae80a673c7674a76a3b67c76f361ad927c9b714b925631e", |
672 | + "size": 9678848, |
673 | + "url": "https://api.snapcraft.io/api/v1/snaps/download/p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM_3.snap" |
674 | + }, |
675 | + "epoch": { |
676 | + "read": [0], |
677 | + "write": [0] |
678 | + }, |
679 | + "revision": 3, |
680 | + "snap-yaml": "name: gif-cli\nversion: 0.0.3\nsummary: CLI tool to search for gifs\ndescription: 'CLI tool to search for gifs\n\n '\nbase: core18\narchitectures:\n- amd64\nconfinement: strict\ngrade: stable\napps:\n gif-cli:\n command: command-gif-cli.wrapper\n plugs:\n - network-bind\n - network\n", |
681 | + "type": "app", |
682 | + "version": "0.0.3" |
683 | + }, { |
684 | + "architectures": ["amd64"], |
685 | + "base": "core18", |
686 | + "channel": { |
687 | + "architecture": "amd64", |
688 | + "name": "edge", |
689 | + "released-at": "2018-10-14T20:49:19.242435+00:00", |
690 | + "risk": "edge", |
691 | + "track": "latest" |
692 | + }, |
693 | + "common-ids": [], |
694 | + "confinement": "strict", |
695 | + "created-at": "2018-10-14T20:48:12.808884+00:00", |
696 | + "download": { |
697 | + "deltas": [], |
698 | + "sha3-384": "32b3f7c734c11fc4902c2d6839f30d22281e27fd06504d72aae80a673c7674a76a3b67c76f361ad927c9b714b925631e", |
699 | + "size": 9678848, |
700 | + "url": "https://api.snapcraft.io/api/v1/snaps/download/p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM_3.snap" |
701 | + }, |
702 | + "epoch": { |
703 | + "read": [0], |
704 | + "write": [0] |
705 | + }, |
706 | + "revision": 3, |
707 | + "snap-yaml": "name: gif-cli\nversion: 0.0.3\nsummary: CLI tool to search for gifs\ndescription: 'CLI tool to search for gifs\n\n '\nbase: core18\narchitectures:\n- amd64\nconfinement: strict\ngrade: stable\napps:\n gif-cli:\n command: command-gif-cli.wrapper\n plugs:\n - network-bind\n - network\n", |
708 | + "type": "app", |
709 | + "version": "0.0.3" |
710 | + }], |
711 | + "name": "gif-cli", |
712 | + "snap": { |
713 | + "license": "unset", |
714 | + "name": "gif-cli", |
715 | + "prices": {}, |
716 | + "publisher": { |
717 | + "display-name": "Tom Wardill", |
718 | + "id": "1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX", |
719 | + "username": "twom", |
720 | + "validation": "unproven" |
721 | + }, |
722 | + "snap-id": "p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM", |
723 | + "summary": "CLI tool to search for gifs", |
724 | + "title": "gif-cli" |
725 | + }, |
726 | + "snap-id": "p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM" |
727 | +} |
728 | diff --git a/snapstore_client/logic/tests/test_login.py b/snapstore_client/logic/tests/test_login.py |
729 | index 25ef14f..d7854cc 100644 |
730 | --- a/snapstore_client/logic/tests/test_login.py |
731 | +++ b/snapstore_client/logic/tests/test_login.py |
732 | @@ -54,11 +54,13 @@ class LoginTests(TestCase): |
733 | iter_responses = iter(full_responses) |
734 | return lambda request: next(iter_responses) |
735 | |
736 | - def make_args(self, store_url=None, sso_url=None, email=None): |
737 | + def make_args(self, store_url=None, |
738 | + sso_url=None, email=None, offline=False): |
739 | return factory.Args( |
740 | store_url=store_url or self.default_gw_url, |
741 | sso_url=sso_url or self.default_sso_url, |
742 | email=email, |
743 | + offline=offline, |
744 | ) |
745 | |
746 | def add_issue_store_admin_response(self, *response_templates, gw_url=None): |
747 | diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py |
748 | index 1f2f2d0..05e32fc 100644 |
749 | --- a/snapstore_client/logic/tests/test_overrides.py |
750 | +++ b/snapstore_client/logic/tests/test_overrides.py |
751 | @@ -32,7 +32,7 @@ class OverridesTests(TestCase): |
752 | 'Have you run "snap-store-proxy-client login"?\n') |
753 | |
754 | @responses.activate |
755 | - def test_list_overrides(self): |
756 | + def test_list_overrides_online(self): |
757 | self.useFixture(testfixtures.ConfigFixture()) |
758 | logger = self.useFixture(fixtures.FakeLogger()) |
759 | snap_id = factory.generate_snap_id() |
760 | @@ -52,18 +52,55 @@ class OverridesTests(TestCase): |
761 | responses.add( |
762 | 'GET', overrides_url, status=200, json={'overrides': overrides}) |
763 | |
764 | - list_overrides(factory.Args(snap_name='mysnap', series='16')) |
765 | + list_overrides( |
766 | + factory.Args(snap_name='mysnap', series='16', password=False)) |
767 | self.assertEqual( |
768 | 'mysnap stable amd64 1 (upstream 2)\n' |
769 | 'mysnap foo/stable i386 3 (upstream 4)\n', |
770 | logger.output) |
771 | + # We shouldn't have Basic Authorization headers, but Macaroon |
772 | + self.assertNotIn( |
773 | + 'Basic', |
774 | + responses.calls[0].request.headers['Authorization']) |
775 | + |
776 | + @responses.activate |
777 | + def test_list_overrides_offline(self): |
778 | + self.useFixture(testfixtures.ConfigFixture()) |
779 | + logger = self.useFixture(fixtures.FakeLogger()) |
780 | + snap_id = factory.generate_snap_id() |
781 | + overrides = [ |
782 | + factory.SnapDeviceGateway.Override( |
783 | + snap_id=snap_id, snap_name='mysnap'), |
784 | + factory.SnapDeviceGateway.Override( |
785 | + snap_id=snap_id, snap_name='mysnap', revision=3, |
786 | + upstream_revision=4, channel='foo/stable', |
787 | + architecture='i386'), |
788 | + ] |
789 | + # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once |
790 | + # they exist. |
791 | + overrides_url = urljoin( |
792 | + config.Config().store_section('default').get('gw_url'), |
793 | + '/v2/metadata/overrides/mysnap') |
794 | + responses.add( |
795 | + 'GET', overrides_url, status=200, json={'overrides': overrides}) |
796 | + |
797 | + list_overrides( |
798 | + factory.Args(snap_name='mysnap', series='16', password='test')) |
799 | + self.assertEqual( |
800 | + 'mysnap stable amd64 1 (upstream 2)\n' |
801 | + 'mysnap foo/stable i386 3 (upstream 4)\n', |
802 | + logger.output) |
803 | + self.assertEqual( |
804 | + 'Basic YWRtaW46dGVzdA==', |
805 | + responses.calls[0].request.headers['Authorization']) |
806 | |
807 | def test_override_no_store_config(self): |
808 | self.useFixture(testfixtures.ConfigFixture(empty=True)) |
809 | logger = self.useFixture(fixtures.FakeLogger()) |
810 | rc = override(factory.Args( |
811 | snap_name='some-snap', channel_map_entries=['stable=1'], |
812 | - series='16')) |
813 | + series='16', |
814 | + password=False)) |
815 | self.assertEqual(rc, 1) |
816 | self.assertEqual( |
817 | logger.output, |
818 | @@ -71,7 +108,56 @@ class OverridesTests(TestCase): |
819 | 'Have you run "snap-store-proxy-client login"?\n') |
820 | |
821 | @responses.activate |
822 | - def test_override(self): |
823 | + def test_override_online(self): |
824 | + self.useFixture(testfixtures.ConfigFixture()) |
825 | + logger = self.useFixture(fixtures.FakeLogger()) |
826 | + snap_id = factory.generate_snap_id() |
827 | + overrides = [ |
828 | + factory.SnapDeviceGateway.Override( |
829 | + snap_id=snap_id, snap_name='mysnap'), |
830 | + factory.SnapDeviceGateway.Override( |
831 | + snap_id=snap_id, snap_name='mysnap', revision=3, |
832 | + upstream_revision=4, channel='foo/stable', |
833 | + architecture='i386'), |
834 | + ] |
835 | + # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once |
836 | + # they exist. |
837 | + overrides_url = urljoin( |
838 | + config.Config().store_section('default').get('gw_url'), |
839 | + '/v2/metadata/overrides') |
840 | + responses.add( |
841 | + 'POST', overrides_url, status=200, json={'overrides': overrides}) |
842 | + |
843 | + override(factory.Args( |
844 | + snap_name='mysnap', |
845 | + channel_map_entries=['stable=1', 'foo/stable=3'], |
846 | + series='16', |
847 | + password=False)) |
848 | + self.assertEqual([ |
849 | + { |
850 | + 'snap_name': 'mysnap', |
851 | + 'revision': 1, |
852 | + 'channel': 'stable', |
853 | + 'series': '16', |
854 | + }, |
855 | + { |
856 | + 'snap_name': 'mysnap', |
857 | + 'revision': 3, |
858 | + 'channel': 'foo/stable', |
859 | + 'series': '16', |
860 | + }, |
861 | + ], json.loads(responses.calls[0].request.body.decode())) |
862 | + self.assertEqual( |
863 | + 'mysnap stable amd64 1 (upstream 2)\n' |
864 | + 'mysnap foo/stable i386 3 (upstream 4)\n', |
865 | + logger.output) |
866 | + # We shouldn't have Basic Authorization headers, but Macaroon |
867 | + self.assertNotIn( |
868 | + 'Basic', |
869 | + responses.calls[0].request.headers['Authorization']) |
870 | + |
871 | + @responses.activate |
872 | + def test_override_offline(self): |
873 | self.useFixture(testfixtures.ConfigFixture()) |
874 | logger = self.useFixture(fixtures.FakeLogger()) |
875 | snap_id = factory.generate_snap_id() |
876 | @@ -94,7 +180,8 @@ class OverridesTests(TestCase): |
877 | override(factory.Args( |
878 | snap_name='mysnap', |
879 | channel_map_entries=['stable=1', 'foo/stable=3'], |
880 | - series='16')) |
881 | + series='16', |
882 | + password='test')) |
883 | self.assertEqual([ |
884 | { |
885 | 'snap_name': 'mysnap', |
886 | @@ -113,12 +200,16 @@ class OverridesTests(TestCase): |
887 | 'mysnap stable amd64 1 (upstream 2)\n' |
888 | 'mysnap foo/stable i386 3 (upstream 4)\n', |
889 | logger.output) |
890 | + self.assertEqual( |
891 | + 'Basic YWRtaW46dGVzdA==', |
892 | + responses.calls[0].request.headers['Authorization']) |
893 | |
894 | def test_delete_override_no_store_config(self): |
895 | self.useFixture(testfixtures.ConfigFixture(empty=True)) |
896 | logger = self.useFixture(fixtures.FakeLogger()) |
897 | rc = delete_override(factory.Args( |
898 | - snap_name='some-snap', channels=['stable'], series='16')) |
899 | + snap_name='some-snap', channels=['stable'], |
900 | + series='16', password=False)) |
901 | self.assertEqual(rc, 1) |
902 | self.assertEqual( |
903 | logger.output, |
904 | @@ -126,7 +217,56 @@ class OverridesTests(TestCase): |
905 | 'Have you run "snap-store-proxy-client login"?\n') |
906 | |
907 | @responses.activate |
908 | - def test_delete_override(self): |
909 | + def test_delete_override_online(self): |
910 | + self.useFixture(testfixtures.ConfigFixture()) |
911 | + logger = self.useFixture(fixtures.FakeLogger()) |
912 | + snap_id = factory.generate_snap_id() |
913 | + overrides = [ |
914 | + factory.SnapDeviceGateway.Override( |
915 | + snap_id=snap_id, snap_name='mysnap', revision=None), |
916 | + factory.SnapDeviceGateway.Override( |
917 | + snap_id=snap_id, snap_name='mysnap', revision=None, |
918 | + upstream_revision=4, channel='foo/stable', |
919 | + architecture='i386'), |
920 | + ] |
921 | + # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once |
922 | + # they exist. |
923 | + overrides_url = urljoin( |
924 | + config.Config().store_section('default').get('gw_url'), |
925 | + '/v2/metadata/overrides') |
926 | + responses.add( |
927 | + 'POST', overrides_url, status=200, json={'overrides': overrides}) |
928 | + |
929 | + delete_override(factory.Args( |
930 | + snap_name='mysnap', |
931 | + channels=['stable', 'foo/stable'], |
932 | + series='16', |
933 | + password=False)) |
934 | + self.assertEqual([ |
935 | + { |
936 | + 'snap_name': 'mysnap', |
937 | + 'revision': None, |
938 | + 'channel': 'stable', |
939 | + 'series': '16', |
940 | + }, |
941 | + { |
942 | + 'snap_name': 'mysnap', |
943 | + 'revision': None, |
944 | + 'channel': 'foo/stable', |
945 | + 'series': '16', |
946 | + }, |
947 | + ], json.loads(responses.calls[0].request.body.decode())) |
948 | + self.assertEqual( |
949 | + 'mysnap stable amd64 is tracking upstream (revision 2)\n' |
950 | + 'mysnap foo/stable i386 is tracking upstream (revision 4)\n', |
951 | + logger.output) |
952 | + # We shouldn't have Basic Authorization headers, but Macaroon |
953 | + self.assertNotIn( |
954 | + 'Basic', |
955 | + responses.calls[0].request.headers['Authorization']) |
956 | + |
957 | + @responses.activate |
958 | + def test_delete_override_offline(self): |
959 | self.useFixture(testfixtures.ConfigFixture()) |
960 | logger = self.useFixture(fixtures.FakeLogger()) |
961 | snap_id = factory.generate_snap_id() |
962 | @@ -149,7 +289,8 @@ class OverridesTests(TestCase): |
963 | delete_override(factory.Args( |
964 | snap_name='mysnap', |
965 | channels=['stable', 'foo/stable'], |
966 | - series='16')) |
967 | + series='16', |
968 | + password='test')) |
969 | self.assertEqual([ |
970 | { |
971 | 'snap_name': 'mysnap', |
972 | @@ -168,3 +309,6 @@ class OverridesTests(TestCase): |
973 | 'mysnap stable amd64 is tracking upstream (revision 2)\n' |
974 | 'mysnap foo/stable i386 is tracking upstream (revision 4)\n', |
975 | logger.output) |
976 | + self.assertEqual( |
977 | + 'Basic YWRtaW46dGVzdA==', |
978 | + responses.calls[0].request.headers['Authorization']) |
979 | diff --git a/snapstore_client/logic/tests/test_push.py b/snapstore_client/logic/tests/test_push.py |
980 | new file mode 100644 |
981 | index 0000000..7fedbbd |
982 | --- /dev/null |
983 | +++ b/snapstore_client/logic/tests/test_push.py |
984 | @@ -0,0 +1,169 @@ |
985 | +import json |
986 | +from pathlib import Path |
987 | +from urllib.parse import urljoin |
988 | + |
989 | +import fixtures |
990 | +import responses |
991 | +from testtools import TestCase |
992 | + |
993 | +from snapstore_client.logic.push import ( |
994 | + ChannelMapExistsException, |
995 | + pushException, |
996 | + _add_assertion_to_service, |
997 | + _split_assertions, |
998 | + _push_channelmap, |
999 | + _push_ident, |
1000 | + _push_revs, |
1001 | +) |
1002 | + |
1003 | + |
1004 | +class pushTests(TestCase): |
1005 | + |
1006 | + def setUp(self): |
1007 | + super().setUp() |
1008 | + self.default_gw_url = 'http://store.local' |
1009 | + self.logger = self.useFixture(fixtures.FakeLogger()) |
1010 | + current_path = Path(__file__).resolve().parent |
1011 | + with (current_path / 'test-snap-map.json').open() as fh: |
1012 | + self.snap_map = json.load(fh) |
1013 | + with (current_path / 'test-snap-assert.assert').open() as fh: |
1014 | + self.snap_assert = fh.read().splitlines() |
1015 | + self.store = {'gw_url': self.default_gw_url} |
1016 | + |
1017 | + @responses.activate |
1018 | + def test_push_ident(self): |
1019 | + ident_url = urljoin(self.default_gw_url, '/snaps/update') |
1020 | + responses.add('POST', ident_url, status=200) |
1021 | + |
1022 | + _push_ident(self.store, 'test', self.snap_map) |
1023 | + |
1024 | + self.assertEqual( |
1025 | + 'Basic YWRtaW46dGVzdA==', |
1026 | + responses.calls[0].request.headers['Authorization']) |
1027 | + |
1028 | + @responses.activate |
1029 | + def test_push_ident_failed(self): |
1030 | + ident_url = urljoin(self.default_gw_url, '/snaps/update') |
1031 | + responses.add('POST', ident_url, status=500) |
1032 | + |
1033 | + self.assertRaises( |
1034 | + pushException, _push_ident, self.store, 'test', self.snap_map) |
1035 | + |
1036 | + @responses.activate |
1037 | + def test_push_revs(self): |
1038 | + revs_url = urljoin(self.default_gw_url, '/revisions/create') |
1039 | + responses.add('POST', revs_url, status=201) |
1040 | + |
1041 | + _push_revs(self.store, 'test', self.snap_map) |
1042 | + |
1043 | + self.assertEqual( |
1044 | + 'Basic YWRtaW46dGVzdA==', |
1045 | + responses.calls[0].request.headers['Authorization']) |
1046 | + |
1047 | + @responses.activate |
1048 | + def test_push_revs_unexpected_status_code(self): |
1049 | + revs_url = urljoin(self.default_gw_url, '/revisions/create') |
1050 | + responses.add('POST', revs_url, status=302) |
1051 | + |
1052 | + self.assertRaises( |
1053 | + pushException, _push_revs, self.store, 'test', self.snap_map) |
1054 | + |
1055 | + @responses.activate |
1056 | + def test_push_revs_failed(self): |
1057 | + revs_url = urljoin(self.default_gw_url, '/revisions/create') |
1058 | + responses.add('POST', revs_url, status=500) |
1059 | + |
1060 | + self.assertRaises( |
1061 | + pushException, _push_revs, self.store, 'test', self.snap_map) |
1062 | + |
1063 | + @responses.activate |
1064 | + def test_push_map(self): |
1065 | + filter_url = urljoin(self.default_gw_url, '/channelmaps/filter') |
1066 | + update_url = urljoin(self.default_gw_url, '/channelmaps/update') |
1067 | + responses.add('POST', filter_url, status=200, json={}) |
1068 | + responses.add('POST', update_url, status=200) |
1069 | + |
1070 | + _push_channelmap(self.store, 'test', self.snap_map) |
1071 | + |
1072 | + self.assertEqual( |
1073 | + 'Basic YWRtaW46dGVzdA==', |
1074 | + responses.calls[0].request.headers['Authorization']) |
1075 | + |
1076 | + @responses.activate |
1077 | + def test_push_map_failed(self): |
1078 | + filter_url = urljoin(self.default_gw_url, '/channelmaps/filter') |
1079 | + update_url = urljoin(self.default_gw_url, '/channelmaps/update') |
1080 | + responses.add('POST', filter_url, status=200, json={}) |
1081 | + responses.add('POST', update_url, status=500) |
1082 | + |
1083 | + self.assertRaises( |
1084 | + pushException, _push_channelmap, |
1085 | + self.store, 'test', self.snap_map) |
1086 | + |
1087 | + @responses.activate |
1088 | + def test_push_map_exists(self): |
1089 | + filter_url = urljoin(self.default_gw_url, '/channelmaps/filter') |
1090 | + update_url = urljoin(self.default_gw_url, '/channelmaps/update') |
1091 | + exists = {'channelmaps': ['thing that exists']} |
1092 | + responses.add('POST', filter_url, status=200, json=exists) |
1093 | + responses.add('POST', update_url, status=200) |
1094 | + |
1095 | + self.assertRaises( |
1096 | + ChannelMapExistsException, _push_channelmap, |
1097 | + self.store, 'test', self.snap_map) |
1098 | + |
1099 | + @responses.activate |
1100 | + def test_push_map_exists_force_push(self): |
1101 | + filter_url = urljoin(self.default_gw_url, '/channelmaps/filter') |
1102 | + update_url = urljoin(self.default_gw_url, '/channelmaps/update') |
1103 | + exists = {'channelmaps': ['thing that exists']} |
1104 | + responses.add('POST', filter_url, status=200, json=exists) |
1105 | + responses.add('POST', update_url, status=200) |
1106 | + |
1107 | + _push_channelmap(self.store, 'test', self.snap_map, True) |
1108 | + |
1109 | + self.assertEqual( |
1110 | + 'Basic YWRtaW46dGVzdA==', |
1111 | + responses.calls[0].request.headers['Authorization']) |
1112 | + |
1113 | + def test_split_assertions(self): |
1114 | + result = _split_assertions(self.snap_assert) |
1115 | + assert len(result) == 4 |
1116 | + |
1117 | + # We should have 4 different types of assertion |
1118 | + assert result[0][0] == 'type: account-key' |
1119 | + assert result[1][0] == 'type: account' |
1120 | + assert result[2][0] == 'type: snap-declaration' |
1121 | + assert result[3][0] == 'type: snap-revision' |
1122 | + |
1123 | + @responses.activate |
1124 | + def test_add_assertion_to_service(self): |
1125 | + assert_url = urljoin(self.default_gw_url, '/v1/assertions') |
1126 | + responses.add('POST', assert_url, status=201) |
1127 | + |
1128 | + split_assertions = _split_assertions(self.snap_assert) |
1129 | + _add_assertion_to_service(self.store, 'test', split_assertions) |
1130 | + |
1131 | + self.assertEqual( |
1132 | + 'Basic YWRtaW46dGVzdA==', |
1133 | + responses.calls[0].request.headers['Authorization']) |
1134 | + |
1135 | + @responses.activate |
1136 | + def test_add_assertion_to_service_unexpected_status_code(self): |
1137 | + assert_url = urljoin(self.default_gw_url, '/v1/assertions') |
1138 | + responses.add('POST', assert_url, status=302) |
1139 | + |
1140 | + split_assertions = _split_assertions(self.snap_assert) |
1141 | + self.assertRaises( |
1142 | + pushException, |
1143 | + _add_assertion_to_service, self.store, 'test', split_assertions) |
1144 | + |
1145 | + @responses.activate |
1146 | + def test_add_assertion_to_service_failed(self): |
1147 | + assert_url = urljoin(self.default_gw_url, '/v1/assertions') |
1148 | + responses.add('POST', assert_url, status=500) |
1149 | + |
1150 | + split_assertions = _split_assertions(self.snap_assert) |
1151 | + self.assertRaises( |
1152 | + pushException, |
1153 | + _add_assertion_to_service, self.store, 'test', split_assertions) |
1154 | diff --git a/snapstore_client/utils.py b/snapstore_client/utils.py |
1155 | new file mode 100644 |
1156 | index 0000000..461a1c3 |
1157 | --- /dev/null |
1158 | +++ b/snapstore_client/utils.py |
1159 | @@ -0,0 +1,26 @@ |
1160 | +import logging |
1161 | + |
1162 | +logger = logging.getLogger(__name__) |
1163 | + |
1164 | + |
1165 | +def _log_credentials_error(e): |
1166 | + logger.error('%s', e) |
1167 | + logger.error('Try to "snap-store-proxy-client login" again.') |
1168 | + |
1169 | + |
1170 | +def _log_authorized_error(): |
1171 | + logger.error(("Perhaps you have not been registered as an " |
1172 | + "admin with the proxy.")) |
1173 | + logger.error("Try 'snap-proxy add-admin' on the proxy host.") |
1174 | + |
1175 | + |
1176 | +def _check_default_store(cfg): |
1177 | + """Load the default store from the config.""" |
1178 | + store = cfg.store_section('default') |
1179 | + # If the gw URL is configured then everything else should be too. |
1180 | + if not store.get('gw_url'): |
1181 | + logger.error( |
1182 | + 'No store configuration found. ' |
1183 | + 'Have you run "snap-store-proxy-client login"?') |
1184 | + return None |
1185 | + return store |
1186 | diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py |
1187 | index e8e618f..3ebf149 100644 |
1188 | --- a/snapstore_client/webservices.py |
1189 | +++ b/snapstore_client/webservices.py |
1190 | @@ -1,6 +1,7 @@ |
1191 | # Copyright 2017 Canonical Ltd. This software is licensed under the |
1192 | # GNU General Public License version 3 (see the file LICENSE). |
1193 | |
1194 | +import base64 |
1195 | import json |
1196 | import logging |
1197 | import urllib.parse |
1198 | @@ -96,6 +97,19 @@ def _get_macaroon_auth(store): |
1199 | root_raw, bound_discharge_raw) |
1200 | |
1201 | |
1202 | +def _get_basic_auth(password): |
1203 | + """Build the basic auth for interacting with an offline proxy""" |
1204 | + # XXX twom 2019-03-15 Hardcoded username, awaiting user management |
1205 | + username = 'admin' |
1206 | + credentials = '{}:{}'.format(username, password) |
1207 | + try: |
1208 | + encoded_credentials = base64.b64encode(credentials.encode('UTF-8')) |
1209 | + except UnicodeEncodeError: |
1210 | + logger.error('Unable to encode password to UTF-8') |
1211 | + raise |
1212 | + return 'Basic {}'.format(encoded_credentials.decode()) |
1213 | + |
1214 | + |
1215 | def _raise_needs_refresh(response): |
1216 | if (response.status_code == 401 and |
1217 | response.headers.get('WWW-Authenticate') == ( |
1218 | @@ -115,15 +129,18 @@ def refresh_if_necessary(store, func, *args, **kwargs): |
1219 | return func(*args, **kwargs) |
1220 | |
1221 | |
1222 | -def get_overrides(store, snap_name, series='16'): |
1223 | +def get_overrides(store, snap_name, series='16', password=None): |
1224 | """Get all overrides for a snap.""" |
1225 | overrides_url = urllib.parse.urljoin( |
1226 | store.get('gw_url'), |
1227 | '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name))) |
1228 | headers = { |
1229 | - 'Authorization': _get_macaroon_auth(store), |
1230 | 'X-Ubuntu-Series': series, |
1231 | } |
1232 | + if password: |
1233 | + headers['Authorization'] = _get_basic_auth(password) |
1234 | + else: |
1235 | + headers['Authorization'] = _get_macaroon_auth(store) |
1236 | resp = requests.get(overrides_url, headers=headers) |
1237 | _raise_needs_refresh(resp) |
1238 | if resp.status_code != 200: |
1239 | @@ -132,11 +149,14 @@ def get_overrides(store, snap_name, series='16'): |
1240 | return resp.json() |
1241 | |
1242 | |
1243 | -def set_overrides(store, overrides): |
1244 | +def set_overrides(store, overrides, password=None): |
1245 | """Add or remove channel map overrides for a snap.""" |
1246 | overrides_url = urllib.parse.urljoin( |
1247 | store.get('gw_url'), '/v2/metadata/overrides') |
1248 | - headers = {'Authorization': _get_macaroon_auth(store)} |
1249 | + if password: |
1250 | + headers = {'Authorization': _get_basic_auth(password)} |
1251 | + else: |
1252 | + headers = {'Authorization': _get_macaroon_auth(store)} |
1253 | resp = requests.post(overrides_url, headers=headers, json=overrides) |
1254 | _raise_needs_refresh(resp) |
1255 | if resp.status_code != 200: |
Looks good to me.