Merge ~cjwatson/snapstore-client:overrides into snapstore-client:master
- Git
- lp:~cjwatson/snapstore-client
- overrides
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | 7c7bc507ad645dbcfea6ba09c6dfb3acad787c95 |
Proposed branch: | ~cjwatson/snapstore-client:overrides |
Merge into: | snapstore-client:master |
Prerequisite: | ~cjwatson/snapstore-client:login |
Diff against target: |
876 lines (+727/-5) 12 files modified
requirements-dev.txt (+1/-0) snapstore (+44/-0) snapstore_client/exceptions.py (+13/-0) snapstore_client/logic/overrides.py (+79/-0) snapstore_client/logic/tests/test_overrides.py (+143/-0) snapstore_client/presentation_helpers.py (+42/-0) snapstore_client/tests/factory.py (+22/-3) snapstore_client/tests/matchers.py (+52/-0) snapstore_client/tests/test_presentation_helpers.py (+107/-0) snapstore_client/tests/test_webservices.py (+92/-1) snapstore_client/tests/testfixtures.py (+43/-1) snapstore_client/webservices.py (+89/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Adam Collard (community) | Approve | ||
Review via email: mp+327372@code.launchpad.net |
Commit message
Add override commands
These allow displaying and modifying channel map overrides in an
enterprise store.
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/requirements-dev.txt b/requirements-dev.txt |
2 | index c29ab6e..999f232 100644 |
3 | --- a/requirements-dev.txt |
4 | +++ b/requirements-dev.txt |
5 | @@ -5,4 +5,5 @@ flake8 |
6 | pysha3>=1.0 |
7 | responses |
8 | snapstore-schemas |
9 | +testscenarios |
10 | testtools |
11 | diff --git a/snapstore b/snapstore |
12 | index a331e6d..2bb9dad 100755 |
13 | --- a/snapstore |
14 | +++ b/snapstore |
15 | @@ -14,6 +14,11 @@ from snapstore_client.logic.dump import ( |
16 | import_snapsection_dump, |
17 | ) |
18 | from snapstore_client.logic.login import login |
19 | +from snapstore_client.logic.overrides import ( |
20 | + delete_override, |
21 | + list_overrides, |
22 | + override, |
23 | +) |
24 | from snapstore_client.logic.upload import upload_snap |
25 | |
26 | |
27 | @@ -55,6 +60,45 @@ def parse_args(): |
28 | login_parser = subparsers.add_parser('login', help='Sign into a store.') |
29 | login_parser.set_defaults(func=login) |
30 | |
31 | + list_overrides_parser = subparsers.add_parser( |
32 | + 'list-overrides', help='List channel map overrides.', |
33 | + ) |
34 | + list_overrides_parser.add_argument( |
35 | + '--series', default='16', |
36 | + help='The series within which to list overrides.') |
37 | + list_overrides_parser.add_argument( |
38 | + 'snap_name', |
39 | + help='The name of the snap whose channel map should be listed.') |
40 | + list_overrides_parser.set_defaults(func=list_overrides) |
41 | + |
42 | + override_parser = subparsers.add_parser( |
43 | + 'override', help='Set channel map overrides.', |
44 | + ) |
45 | + override_parser.add_argument( |
46 | + '--series', default='16', |
47 | + help='The series within which to set overrides.') |
48 | + override_parser.add_argument( |
49 | + 'snap_name', |
50 | + help='The name of the snap whose channel map should be modified.') |
51 | + override_parser.add_argument( |
52 | + 'channel_map_entries', nargs='+', metavar='channel_map_entry', |
53 | + help='A channel map override, in the form <channel>=<revision>.') |
54 | + override_parser.set_defaults(func=override) |
55 | + |
56 | + delete_override_parser = subparsers.add_parser( |
57 | + 'delete-override', help='Delete channel map overrides.', |
58 | + ) |
59 | + delete_override_parser.add_argument( |
60 | + '--series', default='16', |
61 | + help='The series within which to delete overrides.') |
62 | + delete_override_parser.add_argument( |
63 | + 'snap_name', |
64 | + help='The name of the snap whose channel map should be modified.') |
65 | + delete_override_parser.add_argument( |
66 | + 'channels', nargs='+', metavar='channel', |
67 | + help='A channel whose overrides should be deleted.') |
68 | + delete_override_parser.set_defaults(func=delete_override) |
69 | + |
70 | if len(sys.argv) == 1: |
71 | # Display help if no arguments are provided. |
72 | parser.print_help() |
73 | diff --git a/snapstore_client/exceptions.py b/snapstore_client/exceptions.py |
74 | index 5572b8f..88746c7 100644 |
75 | --- a/snapstore_client/exceptions.py |
76 | +++ b/snapstore_client/exceptions.py |
77 | @@ -19,6 +19,14 @@ class ClientError(Exception): |
78 | return self.fmt.format(**self.__dict__) |
79 | |
80 | |
81 | +class InvalidCredentials(ClientError): |
82 | + |
83 | + fmt = 'Invalid credentials: {message}.' |
84 | + |
85 | + def __init__(self, message): |
86 | + super().__init__(message=message) |
87 | + |
88 | + |
89 | class StoreMacaroonSSOMismatch(ClientError): |
90 | |
91 | fmt = 'Root macaroon does not refer to expected SSO host: {sso_host}.' |
92 | @@ -41,3 +49,8 @@ class StoreTwoFactorAuthenticationRequired(StoreAuthenticationError): |
93 | |
94 | def __init__(self): |
95 | super().__init__('Two-factor authentication required.') |
96 | + |
97 | + |
98 | +class StoreMacaroonNeedsRefresh(ClientError): |
99 | + |
100 | + fmt = 'Authentication macaroon needs to be refreshed.' |
101 | diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py |
102 | new file mode 100644 |
103 | index 0000000..f514c48 |
104 | --- /dev/null |
105 | +++ b/snapstore_client/logic/overrides.py |
106 | @@ -0,0 +1,79 @@ |
107 | +# Copyright 2017 Canonical Ltd. |
108 | + |
109 | +import logging |
110 | + |
111 | +from requests.exceptions import HTTPError |
112 | + |
113 | +from snapstore_client import ( |
114 | + exceptions, |
115 | + webservices as ws, |
116 | +) |
117 | +from snapstore_client.presentation_helpers import ( |
118 | + channel_map_string_to_tuple, |
119 | + override_to_string, |
120 | +) |
121 | + |
122 | + |
123 | +logger = logging.getLogger(__name__) |
124 | + |
125 | + |
126 | +def _log_credentials_error(e): |
127 | + logger.error('%s', e) |
128 | + logger.error('Have you run "snapstore login"?') |
129 | + |
130 | + |
131 | +def list_overrides(args): |
132 | + try: |
133 | + response = ws.refresh_if_necessary( |
134 | + ws.get_overrides, args.snap_name, series=args.series) |
135 | + except exceptions.InvalidCredentials as e: |
136 | + _log_credentials_error(e) |
137 | + return 1 |
138 | + except HTTPError: |
139 | + # Already logged. |
140 | + return 1 |
141 | + for override in response['overrides']: |
142 | + logger.info(override_to_string(override)) |
143 | + |
144 | + |
145 | +def override(args): |
146 | + overrides = [] |
147 | + for channel_map_entry in args.channel_map_entries: |
148 | + channel, revision = channel_map_string_to_tuple(channel_map_entry) |
149 | + overrides.append({ |
150 | + 'snap_name': args.snap_name, |
151 | + 'revision': revision, |
152 | + 'channel': channel, |
153 | + 'series': args.series, |
154 | + }) |
155 | + try: |
156 | + response = ws.refresh_if_necessary(ws.set_overrides, overrides) |
157 | + except exceptions.InvalidCredentials as e: |
158 | + _log_credentials_error(e) |
159 | + return 1 |
160 | + except HTTPError: |
161 | + # Already logged. |
162 | + return 1 |
163 | + for override in response['overrides']: |
164 | + logger.info(override_to_string(override)) |
165 | + |
166 | + |
167 | +def delete_override(args): |
168 | + overrides = [] |
169 | + for channel in args.channels: |
170 | + overrides.append({ |
171 | + 'snap_name': args.snap_name, |
172 | + 'revision': None, |
173 | + 'channel': channel, |
174 | + 'series': args.series, |
175 | + }) |
176 | + try: |
177 | + response = ws.refresh_if_necessary(ws.set_overrides, overrides) |
178 | + except exceptions.InvalidCredentials as e: |
179 | + _log_credentials_error(e) |
180 | + return 1 |
181 | + except HTTPError: |
182 | + # Already logged. |
183 | + return 1 |
184 | + for override in response['overrides']: |
185 | + logger.info(override_to_string(override)) |
186 | diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py |
187 | new file mode 100644 |
188 | index 0000000..80a929f |
189 | --- /dev/null |
190 | +++ b/snapstore_client/logic/tests/test_overrides.py |
191 | @@ -0,0 +1,143 @@ |
192 | +# Copyright 2017 Canonical Ltd. |
193 | + |
194 | +import json |
195 | +from urllib.parse import urljoin |
196 | + |
197 | +from acceptable._doubles import set_service_locations |
198 | +import fixtures |
199 | +import responses |
200 | +from testtools import TestCase |
201 | + |
202 | +from snapstore_client import config |
203 | +from snapstore_client.logic.overrides import ( |
204 | + delete_override, |
205 | + list_overrides, |
206 | + override, |
207 | +) |
208 | +from snapstore_client.tests import ( |
209 | + factory, |
210 | + testfixtures, |
211 | +) |
212 | + |
213 | + |
214 | +class OverridesTests(TestCase): |
215 | + |
216 | + def setUp(self): |
217 | + super().setUp() |
218 | + service_locations = config.read_config()['services'] |
219 | + set_service_locations(service_locations) |
220 | + |
221 | + @responses.activate |
222 | + def test_list_overrides(self): |
223 | + logger = self.useFixture(fixtures.FakeLogger()) |
224 | + self.useFixture(testfixtures.CredentialsFixture()) |
225 | + snap_id = factory.generate_snap_id() |
226 | + overrides = [ |
227 | + factory.SnapDeviceGateway.Override( |
228 | + snap_id=snap_id, snap_name='mysnap'), |
229 | + factory.SnapDeviceGateway.Override( |
230 | + snap_id=snap_id, snap_name='mysnap', revision=3, |
231 | + upstream_revision=4, channel='foo/stable', |
232 | + architecture='i386'), |
233 | + ] |
234 | + # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once |
235 | + # they exist. |
236 | + overrides_url = urljoin( |
237 | + config.read_config()['services']['snapdevicegw'], |
238 | + '/v2/metadata/overrides/mysnap') |
239 | + responses.add( |
240 | + 'GET', overrides_url, status=200, json={'overrides': overrides}) |
241 | + |
242 | + list_overrides(factory.Args(snap_name='mysnap', series='16')) |
243 | + self.assertEqual( |
244 | + 'mysnap stable amd64 1 (upstream 2)\n' |
245 | + 'mysnap foo/stable i386 3 (upstream 4)\n', |
246 | + logger.output) |
247 | + |
248 | + @responses.activate |
249 | + def test_override(self): |
250 | + logger = self.useFixture(fixtures.FakeLogger()) |
251 | + self.useFixture(testfixtures.CredentialsFixture()) |
252 | + snap_id = factory.generate_snap_id() |
253 | + overrides = [ |
254 | + factory.SnapDeviceGateway.Override( |
255 | + snap_id=snap_id, snap_name='mysnap'), |
256 | + factory.SnapDeviceGateway.Override( |
257 | + snap_id=snap_id, snap_name='mysnap', revision=3, |
258 | + upstream_revision=4, channel='foo/stable', |
259 | + architecture='i386'), |
260 | + ] |
261 | + # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once |
262 | + # they exist. |
263 | + overrides_url = urljoin( |
264 | + config.read_config()['services']['snapdevicegw'], |
265 | + '/v2/metadata/overrides') |
266 | + responses.add( |
267 | + 'POST', overrides_url, status=200, json={'overrides': overrides}) |
268 | + |
269 | + override(factory.Args( |
270 | + snap_name='mysnap', |
271 | + channel_map_entries=['stable=1', 'foo/stable=3'], |
272 | + series='16')) |
273 | + self.assertEqual([ |
274 | + { |
275 | + 'snap_name': 'mysnap', |
276 | + 'revision': 1, |
277 | + 'channel': 'stable', |
278 | + 'series': '16', |
279 | + }, |
280 | + { |
281 | + 'snap_name': 'mysnap', |
282 | + 'revision': 3, |
283 | + 'channel': 'foo/stable', |
284 | + 'series': '16', |
285 | + }, |
286 | + ], json.loads(responses.calls[0].request.body.decode())) |
287 | + self.assertEqual( |
288 | + 'mysnap stable amd64 1 (upstream 2)\n' |
289 | + 'mysnap foo/stable i386 3 (upstream 4)\n', |
290 | + logger.output) |
291 | + |
292 | + @responses.activate |
293 | + def test_delete_override(self): |
294 | + logger = self.useFixture(fixtures.FakeLogger()) |
295 | + self.useFixture(testfixtures.CredentialsFixture()) |
296 | + snap_id = factory.generate_snap_id() |
297 | + overrides = [ |
298 | + factory.SnapDeviceGateway.Override( |
299 | + snap_id=snap_id, snap_name='mysnap', revision=None), |
300 | + factory.SnapDeviceGateway.Override( |
301 | + snap_id=snap_id, snap_name='mysnap', revision=None, |
302 | + upstream_revision=4, channel='foo/stable', |
303 | + architecture='i386'), |
304 | + ] |
305 | + # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once |
306 | + # they exist. |
307 | + overrides_url = urljoin( |
308 | + config.read_config()['services']['snapdevicegw'], |
309 | + '/v2/metadata/overrides') |
310 | + responses.add( |
311 | + 'POST', overrides_url, status=200, json={'overrides': overrides}) |
312 | + |
313 | + delete_override(factory.Args( |
314 | + snap_name='mysnap', |
315 | + channels=['stable', 'foo/stable'], |
316 | + series='16')) |
317 | + self.assertEqual([ |
318 | + { |
319 | + 'snap_name': 'mysnap', |
320 | + 'revision': None, |
321 | + 'channel': 'stable', |
322 | + 'series': '16', |
323 | + }, |
324 | + { |
325 | + 'snap_name': 'mysnap', |
326 | + 'revision': None, |
327 | + 'channel': 'foo/stable', |
328 | + 'series': '16', |
329 | + }, |
330 | + ], json.loads(responses.calls[0].request.body.decode())) |
331 | + self.assertEqual( |
332 | + 'mysnap stable amd64 is tracking upstream (revision 2)\n' |
333 | + 'mysnap foo/stable i386 is tracking upstream (revision 4)\n', |
334 | + logger.output) |
335 | diff --git a/snapstore_client/presentation_helpers.py b/snapstore_client/presentation_helpers.py |
336 | new file mode 100644 |
337 | index 0000000..74e47b1 |
338 | --- /dev/null |
339 | +++ b/snapstore_client/presentation_helpers.py |
340 | @@ -0,0 +1,42 @@ |
341 | +# Copyright 2017 Canonical Ltd. |
342 | + |
343 | +"""Helpers for the presentation layer. |
344 | + |
345 | +This module contains various functions to make formatting responses as easy |
346 | +as possible. |
347 | + |
348 | +Please DO NOT make calls to other services from this module! Doing so obscures |
349 | +the log of the view in question. |
350 | +""" |
351 | + |
352 | + |
353 | +def channel_map_string_to_tuple(channel_map_string): |
354 | + """Convert from a channel map string to a tuple. |
355 | + |
356 | + The right-hand side is converted to an integer. |
357 | + |
358 | + 'edge=1' becomes ('edge', 1). |
359 | + """ |
360 | + parts = channel_map_string.rsplit('=', 1) |
361 | + if len(parts) != 2: |
362 | + raise ValueError("Invalid channel map string: %r" % channel_map_string) |
363 | + channel = parts[0] |
364 | + try: |
365 | + revision = int(parts[1]) |
366 | + except ValueError: |
367 | + raise ValueError("Invalid revision string: %r" % parts[1]) |
368 | + return channel, revision |
369 | + |
370 | + |
371 | +def override_to_string(override): |
372 | + """Convert a channel map override into a string presentation.""" |
373 | + template = '{snap_name} {channel} {architecture}' |
374 | + if override['revision'] is None: |
375 | + template += ' is tracking upstream' |
376 | + if override['upstream_revision'] is not None: |
377 | + template += ' (revision {upstream_revision:d})' |
378 | + else: |
379 | + template += ' {revision:d}' |
380 | + if override['upstream_revision'] is not None: |
381 | + template += ' (upstream {upstream_revision:d})' |
382 | + return template.format_map(override) |
383 | diff --git a/snapstore_client/tests/factory.py b/snapstore_client/tests/factory.py |
384 | index 250fafa..d6a74ce 100644 |
385 | --- a/snapstore_client/tests/factory.py |
386 | +++ b/snapstore_client/tests/factory.py |
387 | @@ -2,8 +2,8 @@ |
388 | |
389 | """Test Factory. |
390 | |
391 | -Since siab-client does not have any database, its persistence layer consists |
392 | -of making requests to other services in the store. |
393 | +Since snapstore-client does not have any database, its persistence layer |
394 | +consists of making requests to other services in the store. |
395 | |
396 | The test factory therefore makes it easy to create payloads that are |
397 | consistent with what those other services return from their various API |
398 | @@ -237,7 +237,7 @@ class SnapRevsBuilder: |
399 | } |
400 | |
401 | |
402 | -class SnapRevs(): |
403 | +class SnapRevs: |
404 | |
405 | @staticmethod |
406 | def NoRevisions(): |
407 | @@ -252,6 +252,25 @@ class SnapRevs(): |
408 | return {'revisions': builder.get_payload()['revisions']} |
409 | |
410 | |
411 | +class SnapDeviceGateway: |
412 | + |
413 | + @staticmethod |
414 | + def Override(snap_id=None, snap_name='special-sauce', revision=1, |
415 | + upstream_revision=2, channel='stable', architecture='amd64', |
416 | + series='16'): |
417 | + if snap_id is None: |
418 | + snap_id = generate_snap_id() |
419 | + return { |
420 | + 'snap_id': snap_id, |
421 | + 'snap_name': snap_name, |
422 | + 'revision': revision, |
423 | + 'upstream_revision': upstream_revision, |
424 | + 'channel': channel, |
425 | + 'architecture': architecture, |
426 | + 'series': series, |
427 | + } |
428 | + |
429 | + |
430 | class Args: |
431 | """Fake arguments for testing command-line logic.""" |
432 | |
433 | diff --git a/snapstore_client/tests/matchers.py b/snapstore_client/tests/matchers.py |
434 | new file mode 100644 |
435 | index 0000000..29e10b7 |
436 | --- /dev/null |
437 | +++ b/snapstore_client/tests/matchers.py |
438 | @@ -0,0 +1,52 @@ |
439 | +# Copyright 2017 Canonical Ltd. This software is licensed under the |
440 | +# GNU General Public License version 3 (see the file LICENSE). |
441 | + |
442 | +from pymacaroons import ( |
443 | + Macaroon, |
444 | + Verifier, |
445 | +) |
446 | +from requests.utils import parse_dict_header |
447 | +from testtools.matchers import ( |
448 | + Contains, |
449 | + Matcher, |
450 | + Mismatch, |
451 | + StartsWith, |
452 | +) |
453 | + |
454 | + |
455 | +class MacaroonsVerify(Matcher): |
456 | + """Matches if serialised macaroons pass verification.""" |
457 | + |
458 | + def __init__(self, key): |
459 | + super().__init__() |
460 | + self.key = key |
461 | + |
462 | + def match(self, macaroons): |
463 | + mismatch = Contains('root').match(macaroons) |
464 | + if mismatch is not None: |
465 | + return mismatch |
466 | + root_macaroon = Macaroon.deserialize(macaroons['root']) |
467 | + if 'discharge' in macaroons: |
468 | + discharge_macaroons = [ |
469 | + Macaroon.deserialize(macaroons['discharge'])] |
470 | + else: |
471 | + discharge_macaroons = [] |
472 | + try: |
473 | + Verifier().verify(root_macaroon, self.key, discharge_macaroons) |
474 | + except Exception as e: |
475 | + return Mismatch('Macaroons do not verify: %s' % e) |
476 | + |
477 | + |
478 | +class MacaroonHeaderVerifies(Matcher): |
479 | + """Matches if an Authorization header passes verification.""" |
480 | + |
481 | + def __init__(self, key): |
482 | + super().__init__() |
483 | + self.key = key |
484 | + |
485 | + def match(self, authz_header): |
486 | + mismatch = StartsWith('Macaroon ').match(authz_header) |
487 | + if mismatch is not None: |
488 | + return mismatch |
489 | + return MacaroonsVerify(self.key).match( |
490 | + parse_dict_header(authz_header[len('Macaroon '):])) |
491 | diff --git a/snapstore_client/tests/test_presentation_helpers.py b/snapstore_client/tests/test_presentation_helpers.py |
492 | new file mode 100644 |
493 | index 0000000..b46345d |
494 | --- /dev/null |
495 | +++ b/snapstore_client/tests/test_presentation_helpers.py |
496 | @@ -0,0 +1,107 @@ |
497 | +# Copyright 2017 Canonical Ltd. |
498 | + |
499 | +from testtools import TestCase |
500 | +from testscenarios import WithScenarios |
501 | + |
502 | +from snapstore_client.presentation_helpers import ( |
503 | + channel_map_string_to_tuple, |
504 | + override_to_string, |
505 | +) |
506 | + |
507 | + |
508 | +class ChannelMapStringToTupleScenarioTests(WithScenarios, TestCase): |
509 | + |
510 | + scenarios = [ |
511 | + ('with risk', { |
512 | + 'channel_map': 'stable=1', |
513 | + 'terms': ('stable', 1), |
514 | + }), |
515 | + ('with track and risk', { |
516 | + 'channel_map': '2.1/stable=2', |
517 | + 'terms': ('2.1/stable', 2), |
518 | + }), |
519 | + ('with risk and branch', { |
520 | + 'channel_map': 'stable/hot-fix=42', |
521 | + 'terms': ('stable/hot-fix', 42), |
522 | + }), |
523 | + ('with track, risk and branch', { |
524 | + 'channel_map': '2.1/stable/hot-fix=123', |
525 | + 'terms': ('2.1/stable/hot-fix', 123), |
526 | + }), |
527 | + ] |
528 | + |
529 | + def test_run_scenario(self): |
530 | + self.assertEqual( |
531 | + self.terms, channel_map_string_to_tuple(self.channel_map)) |
532 | + |
533 | + |
534 | +class ChannelMapStringToTupleErrorTests(WithScenarios, TestCase): |
535 | + |
536 | + scenarios = [ |
537 | + ('missing revision', { |
538 | + 'channel_map': 'stable', |
539 | + 'error_message': "Invalid channel map string: 'stable'", |
540 | + }), |
541 | + ('non-integer revision', { |
542 | + 'channel_map': 'stable=nonsense', |
543 | + 'error_message': "Invalid revision string: 'nonsense'", |
544 | + }), |
545 | + ] |
546 | + |
547 | + def test_run_scenario(self): |
548 | + error = self.assertRaises( |
549 | + ValueError, channel_map_string_to_tuple, self.channel_map) |
550 | + self.assertEqual(self.error_message, str(error)) |
551 | + |
552 | + |
553 | +class OverrideToStringTests(WithScenarios, TestCase): |
554 | + |
555 | + scenarios = [ |
556 | + ('without revision or upstream revision', { |
557 | + 'override': { |
558 | + 'snap_id': 'dummy', |
559 | + 'snap_name': 'mysnap', |
560 | + 'revision': None, |
561 | + 'upstream_revision': None, |
562 | + 'channel': 'stable', |
563 | + 'architecture': 'amd64', |
564 | + }, |
565 | + 'string': 'mysnap stable amd64 is tracking upstream', |
566 | + }), |
567 | + ('without revision but with upstream revision', { |
568 | + 'override': { |
569 | + 'snap_id': 'dummy', |
570 | + 'snap_name': 'mysnap', |
571 | + 'revision': None, |
572 | + 'upstream_revision': 2, |
573 | + 'channel': 'stable', |
574 | + 'architecture': 'amd64', |
575 | + }, |
576 | + 'string': 'mysnap stable amd64 is tracking upstream (revision 2)', |
577 | + }), |
578 | + ('with revision but without upstream revision', { |
579 | + 'override': { |
580 | + 'snap_id': 'dummy', |
581 | + 'snap_name': 'mysnap', |
582 | + 'revision': 1, |
583 | + 'upstream_revision': None, |
584 | + 'channel': 'stable', |
585 | + 'architecture': 'amd64', |
586 | + }, |
587 | + 'string': 'mysnap stable amd64 1', |
588 | + }), |
589 | + ('with revision and upstream revision', { |
590 | + 'override': { |
591 | + 'snap_id': 'dummy', |
592 | + 'snap_name': 'mysnap', |
593 | + 'revision': 1, |
594 | + 'upstream_revision': 2, |
595 | + 'channel': 'stable', |
596 | + 'architecture': 'amd64', |
597 | + }, |
598 | + 'string': 'mysnap stable amd64 1 (upstream 2)', |
599 | + }), |
600 | + ] |
601 | + |
602 | + def test_run_scenario(self): |
603 | + self.assertEqual(self.string, override_to_string(self.override)) |
604 | diff --git a/snapstore_client/tests/test_webservices.py b/snapstore_client/tests/test_webservices.py |
605 | index b688f88..2d012ba 100644 |
606 | --- a/snapstore_client/tests/test_webservices.py |
607 | +++ b/snapstore_client/tests/test_webservices.py |
608 | @@ -25,7 +25,11 @@ from snapstore_client import ( |
609 | exceptions, |
610 | webservices, |
611 | ) |
612 | -from snapstore_client.tests import factory |
613 | +from snapstore_client.tests import ( |
614 | + factory, |
615 | + matchers, |
616 | + testfixtures, |
617 | +) |
618 | |
619 | if sys.version < '3.6': |
620 | import sha3 # noqa |
621 | @@ -550,3 +554,90 @@ class WebservicesTests(TestCase): |
622 | 'Try again later.\n' |
623 | '====================\n', |
624 | logger.output) |
625 | + |
626 | + @responses.activate |
627 | + def test_get_overrides_success(self): |
628 | + logger = self.useFixture(fixtures.FakeLogger()) |
629 | + credentials = self.useFixture(testfixtures.CredentialsFixture()) |
630 | + overrides = [factory.SnapDeviceGateway.Override()] |
631 | + # XXX cjwatson 2017-06-26: Use acceptable-generated double once it |
632 | + # exists. |
633 | + overrides_url = urljoin( |
634 | + config.read_config()['services']['snapdevicegw'], |
635 | + '/v2/metadata/overrides/mysnap') |
636 | + responses.add('GET', overrides_url, status=200, json=overrides) |
637 | + |
638 | + self.assertEqual(overrides, webservices.get_overrides('mysnap')) |
639 | + request = responses.calls[0].request |
640 | + self.assertThat( |
641 | + request.headers['Authorization'], |
642 | + matchers.MacaroonHeaderVerifies(credentials.key)) |
643 | + self.assertNotIn('Failed to get overrides:', logger.output) |
644 | + |
645 | + @responses.activate |
646 | + def test_get_overrides_error(self): |
647 | + logger = self.useFixture(fixtures.FakeLogger()) |
648 | + self.useFixture(testfixtures.CredentialsFixture()) |
649 | + overrides_url = urljoin( |
650 | + config.read_config()['services']['snapdevicegw'], |
651 | + '/v2/metadata/overrides/mysnap') |
652 | + responses.add( |
653 | + 'GET', overrides_url, status=400, |
654 | + json=factory.APIError.single('Something went wrong').to_dict()) |
655 | + |
656 | + self.assertRaises(HTTPError, webservices.get_overrides, 'mysnap') |
657 | + self.assertEqual( |
658 | + 'Failed to get overrides:\nSomething went wrong\n', logger.output) |
659 | + |
660 | + @responses.activate |
661 | + def test_set_overrides_success(self): |
662 | + logger = self.useFixture(fixtures.FakeLogger()) |
663 | + credentials = self.useFixture(testfixtures.CredentialsFixture()) |
664 | + override = factory.SnapDeviceGateway.Override() |
665 | + # XXX cjwatson 2017-06-26: Use acceptable-generated double once it |
666 | + # exists. |
667 | + overrides_url = urljoin( |
668 | + config.read_config()['services']['snapdevicegw'], |
669 | + '/v2/metadata/overrides') |
670 | + responses.add('POST', overrides_url, status=200, json=[override]) |
671 | + |
672 | + self.assertEqual([override], webservices.set_overrides([{ |
673 | + 'snap_name': override['snap_name'], |
674 | + 'revision': override['revision'], |
675 | + 'channel': override['channel'], |
676 | + 'series': override['series'], |
677 | + }])) |
678 | + request = responses.calls[0].request |
679 | + self.assertThat( |
680 | + request.headers['Authorization'], |
681 | + matchers.MacaroonHeaderVerifies(credentials.key)) |
682 | + self.assertEqual([{ |
683 | + 'snap_name': override['snap_name'], |
684 | + 'revision': override['revision'], |
685 | + 'channel': override['channel'], |
686 | + 'series': override['series'], |
687 | + }], json.loads(request.body.decode())) |
688 | + self.assertNotIn('Failed to set override:', logger.output) |
689 | + |
690 | + @responses.activate |
691 | + def test_set_overrides_error(self): |
692 | + logger = self.useFixture(fixtures.FakeLogger()) |
693 | + self.useFixture(testfixtures.CredentialsFixture()) |
694 | + override = factory.SnapDeviceGateway.Override() |
695 | + # XXX cjwatson 2017-06-26: Use acceptable-generated double once it |
696 | + # exists. |
697 | + overrides_url = urljoin( |
698 | + config.read_config()['services']['snapdevicegw'], |
699 | + '/v2/metadata/overrides') |
700 | + responses.add( |
701 | + 'POST', overrides_url, status=400, |
702 | + json=factory.APIError.single('Something went wrong').to_dict()) |
703 | + |
704 | + self.assertRaises(HTTPError, webservices.set_overrides, { |
705 | + 'snap_name': override['snap_name'], |
706 | + 'revision': override['revision'], |
707 | + 'channel': override['channel'], |
708 | + 'series': override['series'], |
709 | + }) |
710 | + self.assertEqual( |
711 | + 'Failed to set override:\nSomething went wrong\n', logger.output) |
712 | diff --git a/snapstore_client/tests/testfixtures.py b/snapstore_client/tests/testfixtures.py |
713 | index f659cd9..5070d5a 100644 |
714 | --- a/snapstore_client/tests/testfixtures.py |
715 | +++ b/snapstore_client/tests/testfixtures.py |
716 | @@ -1,6 +1,15 @@ |
717 | # Copyright 2017 Canonical Ltd. |
718 | |
719 | -from fixtures import Fixture |
720 | +import os.path |
721 | +from textwrap import dedent |
722 | +from urllib.parse import urlparse |
723 | + |
724 | +from fixtures import ( |
725 | + Fixture, |
726 | + MonkeyPatch, |
727 | + TempDir, |
728 | +) |
729 | +from pymacaroons import Macaroon |
730 | |
731 | from snapstore_client import config |
732 | |
733 | @@ -13,3 +22,36 @@ class ConfigOverrideFixture(Fixture): |
734 | |
735 | def _setUp(self): |
736 | self.addCleanup(config.read_config.override_for_test(self._new_config)) |
737 | + |
738 | + |
739 | +class CredentialsFixture(Fixture): |
740 | + |
741 | + def __init__(self): |
742 | + super().__init__() |
743 | + self.key = 'random-key' |
744 | + self.root = Macaroon(key=self.key) |
745 | + self.root.add_third_party_caveat( |
746 | + 'login.example.com', 'sso-key', 'payload') |
747 | + self.unbound_discharge = Macaroon( |
748 | + location='login.example.com', identifier='payload', key='sso-key') |
749 | + |
750 | + def _setUp(self): |
751 | + config_path = self.useFixture(TempDir()).path |
752 | + credentials_path = os.path.join( |
753 | + config_path, 'snapstore-client', 'credentials.cfg') |
754 | + os.makedirs(os.path.dirname(credentials_path)) |
755 | + snapdevicegw_netloc = urlparse( |
756 | + config.read_config()['services']['snapdevicegw']).netloc |
757 | + with open(credentials_path, 'w') as credentials_file: |
758 | + credentials = dedent("""\ |
759 | + [{snapdevicegw}] |
760 | + root = {root} |
761 | + unbound_discharge = {unbound_discharge} |
762 | + """).format( |
763 | + snapdevicegw=snapdevicegw_netloc, |
764 | + root=self.root.serialize(), |
765 | + unbound_discharge=self.unbound_discharge.serialize(), |
766 | + ) |
767 | + print(credentials, file=credentials_file) |
768 | + self.useFixture(MonkeyPatch( |
769 | + 'xdg.BaseDirectory.xdg_config_dirs', [config_path])) |
770 | diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py |
771 | index b9306fa..3ec74b6 100644 |
772 | --- a/snapstore_client/webservices.py |
773 | +++ b/snapstore_client/webservices.py |
774 | @@ -9,6 +9,7 @@ import logging |
775 | import os.path |
776 | import urllib.parse |
777 | |
778 | +from pymacaroons import Macaroon |
779 | import requests |
780 | |
781 | from snapstore_client import ( |
782 | @@ -303,6 +304,94 @@ def get_sso_discharge(email, password, caveat_id, one_time_password=None): |
783 | return resp.json()['discharge_macaroon'] |
784 | |
785 | |
786 | +def refresh_sso_discharge(unbound_discharge_raw): |
787 | + sso_root = config.read_config()['services']['sso'] |
788 | + refresh_url = urllib.parse.urljoin(sso_root, '/api/v2/tokens/refresh') |
789 | + data = {'discharge_macaroon': unbound_discharge_raw} |
790 | + resp = requests.post( |
791 | + refresh_url, headers={'Accept': 'application/json'}, json=data) |
792 | + if not resp.ok: |
793 | + _print_error_message('refresh SSO discharge', resp) |
794 | + resp.raise_for_status() |
795 | + return resp.json()['discharge_macaroon'] |
796 | + |
797 | + |
798 | +def _deserialize_macaroon(name, value): |
799 | + if value is None: |
800 | + raise exceptions.InvalidCredentials('no {} macaroon'.format(name)) |
801 | + try: |
802 | + return Macaroon.deserialize(value) |
803 | + except Exception: |
804 | + raise exceptions.InvalidCredentials( |
805 | + 'failed to deserialize {} macaroon'.format(name)) |
806 | + |
807 | + |
808 | +def _get_macaroon_auth(): |
809 | + """Return an Authorization header containing store macaroons.""" |
810 | + credentials = config.Credentials() |
811 | + root_raw = credentials.get('root') |
812 | + root = _deserialize_macaroon('root', root_raw) |
813 | + unbound_discharge_raw = credentials.get('unbound_discharge') |
814 | + unbound_discharge = _deserialize_macaroon( |
815 | + 'unbound discharge', unbound_discharge_raw) |
816 | + bound_discharge = root.prepare_for_request(unbound_discharge) |
817 | + bound_discharge_raw = bound_discharge.serialize() |
818 | + return 'Macaroon root="{}", discharge="{}"'.format( |
819 | + root_raw, bound_discharge_raw) |
820 | + |
821 | + |
822 | +def _raise_needs_refresh(response): |
823 | + if (response.status_code == 401 and |
824 | + response.headers.get('WWW-Authenticate') == ( |
825 | + 'Macaroon needs_refresh=1')): |
826 | + raise exceptions.StoreMacaroonNeedsRefresh() |
827 | + |
828 | + |
829 | +def refresh_if_necessary(func, *args, **kwargs): |
830 | + """Make a request, refreshing macaroons if necessary.""" |
831 | + try: |
832 | + return func(*args, **kwargs) |
833 | + except exceptions.StoreMacaroonNeedsRefresh: |
834 | + credentials = config.Credentials() |
835 | + unbound_discharge = refresh_sso_discharge( |
836 | + credentials.get('unbound_discharge')) |
837 | + credentials.set('unbound_discharge', unbound_discharge) |
838 | + credentials.save() |
839 | + return func(*args, **kwargs) |
840 | + |
841 | + |
842 | +def get_overrides(snap_name, series='16'): |
843 | + """Get all overrides for a snap.""" |
844 | + devicegw_root = config.read_config()['services']['snapdevicegw'] |
845 | + overrides_url = urllib.parse.urljoin( |
846 | + devicegw_root, |
847 | + '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name))) |
848 | + headers = { |
849 | + 'Authorization': _get_macaroon_auth(), |
850 | + 'X-Ubuntu-Series': series, |
851 | + } |
852 | + resp = requests.get(overrides_url, headers=headers) |
853 | + _raise_needs_refresh(resp) |
854 | + if resp.status_code != 200: |
855 | + _print_error_message('get overrides', resp) |
856 | + resp.raise_for_status() |
857 | + return resp.json() |
858 | + |
859 | + |
860 | +def set_overrides(overrides): |
861 | + """Add or remove channel map overrides for a snap.""" |
862 | + devicegw_root = config.read_config()['services']['snapdevicegw'] |
863 | + overrides_url = urllib.parse.urljoin( |
864 | + devicegw_root, '/v2/metadata/overrides') |
865 | + headers = {'Authorization': _get_macaroon_auth()} |
866 | + resp = requests.post(overrides_url, headers=headers, json=overrides) |
867 | + _raise_needs_refresh(resp) |
868 | + if resp.status_code != 200: |
869 | + _print_error_message('set override', resp) |
870 | + resp.raise_for_status() |
871 | + return resp.json() |
872 | + |
873 | + |
874 | def _print_error_message(action, response): |
875 | """Print failure messages from other services in a standard way.""" |
876 | logger.error("Failed to %s:", action) |
Looks good, just a subjective bike-shed nitpick inline :)