Merge ~cjwatson/snapstore-client:overrides into snapstore-client: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)
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.

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) wrote :

Looks good, just a subjective bike-shed nitpick inline :)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/requirements-dev.txt b/requirements-dev.txt
2index 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
11diff --git a/snapstore b/snapstore
12index 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()
73diff --git a/snapstore_client/exceptions.py b/snapstore_client/exceptions.py
74index 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.'
101diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py
102new file mode 100644
103index 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))
186diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py
187new file mode 100644
188index 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)
335diff --git a/snapstore_client/presentation_helpers.py b/snapstore_client/presentation_helpers.py
336new file mode 100644
337index 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)
383diff --git a/snapstore_client/tests/factory.py b/snapstore_client/tests/factory.py
384index 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
433diff --git a/snapstore_client/tests/matchers.py b/snapstore_client/tests/matchers.py
434new file mode 100644
435index 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 '):]))
491diff --git a/snapstore_client/tests/test_presentation_helpers.py b/snapstore_client/tests/test_presentation_helpers.py
492new file mode 100644
493index 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))
604diff --git a/snapstore_client/tests/test_webservices.py b/snapstore_client/tests/test_webservices.py
605index 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)
712diff --git a/snapstore_client/tests/testfixtures.py b/snapstore_client/tests/testfixtures.py
713index 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]))
770diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py
771index 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)

Subscribers

People subscribed via source and target branches

to all changes: