Merge lp:~makyo/charms/trusty/juju-gui/guiserver-v4bundle into lp:charms/trusty/juju-gui

Proposed by Madison Scott-Clary on 2015-02-10
Status: Merged
Merged at revision: 110
Proposed branch: lp:~makyo/charms/trusty/juju-gui/guiserver-v4bundle
Merge into: lp:charms/trusty/juju-gui
Diff against target: 513 lines (+274/-27)
6 files modified
server/guiserver/bundles/__init__.py (+23/-7)
server/guiserver/bundles/base.py (+4/-3)
server/guiserver/bundles/views.py (+17/-4)
server/guiserver/tests/bundles/test_base.py (+81/-7)
server/guiserver/tests/bundles/test_views.py (+140/-4)
server/guiserver/tests/helpers.py (+9/-2)
To merge this branch: bzr merge lp:~makyo/charms/trusty/juju-gui/guiserver-v4bundle
Reviewer Review Type Date Requested Status
Juju GUI Charmers 2015-02-10 Pending
Review via email: mp+249236@code.launchpad.net

Description of the Change

Adds support for v4 (basketless) bundles as used by the charmstore. This retains legacy v3 functionality for the time being.

To post a comment you must log in.
Francesco Banconi (frankban) wrote :

Hi Madison,

could you please re-propose this against the development branch?
It's lp:~juju-gui/charms/trusty/juju-gui/trunk

Thank you.

113. By Madison Scott-Clary on 2015-02-12

Reduce code repetition by adding a version to the params

114. By Madison Scott-Clary on 2015-02-13

Reduce repetition in views

115. By Madison Scott-Clary on 2015-02-17

Update docs to refer to v4 bundles

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'server/guiserver/bundles/__init__.py'
2--- server/guiserver/bundles/__init__.py 2013-11-15 12:41:59 +0000
3+++ server/guiserver/bundles/__init__.py 2015-02-17 17:42:45 +0000
4@@ -30,7 +30,9 @@
5 The following arguments are passed to the validate and import_bundle
6 interface methods:
7 - user: a guiserver.auth.User instance, representing a logged in user;
8- - name: a string representing the name of the bundle to be imported;
9+ - name: a string representing the name of the bundle to be imported; in
10+ the case of v3 bundles, this will be the name of the bundle within
11+ the basket, in v4 bundles, this will be the bundle ID;
12 - bundle: a YAML decoded object representing the bundle contents.
13 The watch and next interface methods are used to retrieve information
14 about the status of the currently started/scheduled deployments.
15@@ -87,7 +89,7 @@
16 Importing a bundle.
17 -------------------
18
19-A deployment request looks like the following:
20+A v3 deployment request looks like the following:
21
22 {
23 'RequestId': 1,
24@@ -100,12 +102,26 @@
25 },
26 }
27
28+A v4 deployment request looks like the following:
29+
30+ {
31+ 'RequestId': 1,
32+ 'Type': 'Deployer',
33+ 'Request': 'Import',
34+ 'Params': {
35+ 'Version': 4,
36+ 'YAML': 'bundle',
37+ 'BundleID': 'id'
38+ },
39+ }
40+
41 In the request parameters above, the YAML field stores the YAML encoded
42-contents representing one or more bundles, and the Name field is the name of
43-the specific bundle (included in YAML) that must be deployed. The Name
44-parameter is optional in the case YAML includes only one bundle. The BundleID
45-is optional and is used for incrementing the deployment counter in
46-Charmworld.
47+contents representing one (or more, in the case of v3 baskets) bundles, and the
48+Name field is the name of the specific bundle (included in YAML) that must be
49+deployed. The Name parameter is optional in the case YAML includes only one
50+bundle, or in the case of v4 bundles. The BundleID is optional for v3 bundles
51+and required for v4 bundles, and is used for incrementing the deployment
52+counter in Charmworld.
53
54 After receiving a deployment request, the DeployMiddleware sends a response
55 indicating whether or not the request has been accepted. This response is sent
56
57=== modified file 'server/guiserver/bundles/base.py'
58--- server/guiserver/bundles/base.py 2014-12-17 13:19:51 +0000
59+++ server/guiserver/bundles/base.py 2015-02-17 17:42:45 +0000
60@@ -97,7 +97,7 @@
61 self.importer_options = blocking.get_default_guiserver_options()
62
63 @gen.coroutine
64- def validate(self, user, name, bundle):
65+ def validate(self, user, bundle):
66 """Validate the deployment bundle.
67
68 The validation is executed in a separate process using the
69@@ -105,7 +105,6 @@
70
71 Three arguments are provided:
72 - user: the current authenticated user;
73- - name: then name of the bundle to be imported;
74 - bundle: a YAML decoded object representing the bundle contents.
75
76 Return a Future whose result is a string representing an error or None
77@@ -272,6 +271,7 @@
78 self._deployer = deployer
79 self._write_response = write_response
80 self.routes = {
81+ # Default import route
82 'Import': views.import_bundle,
83 'Watch': views.watch,
84 'Next': views.next,
85@@ -291,8 +291,9 @@
86 def process_request(self, data):
87 """Process a deployment request."""
88 request_id = data['RequestId']
89+ params = data.get('Params', {})
90 view = self.routes[data['Request']]
91- request = ObjectDict(params=data.get('Params', {}), user=self._user)
92+ request = ObjectDict(params=params, user=self._user)
93 response = yield view(request, self._deployer)
94 response['RequestId'] = request_id
95 self._write_response(response)
96
97=== modified file 'server/guiserver/bundles/views.py'
98--- server/guiserver/bundles/views.py 2013-11-19 14:56:29 +0000
99+++ server/guiserver/bundles/views.py 2015-02-17 17:42:45 +0000
100@@ -76,12 +76,15 @@
101 """Parse the request data and return a (name, bundle, bundle_id) tuple.
102
103 In the tuple:
104- - name is the name of the bundle to be imported;
105+ - name is the name of the bundle to be imported; this is required for old
106+ style bundles, not for v4 bundles
107 - bundle is the YAML decoded bundle object.
108- - bundle_id is the permanent id of the bundle of the form
109+ - bundle_id is the permanent id of the bundle, in v3 of the form
110 ~user/basketname/version/bundlename, e.g.
111 ~jorge/mediawiki/3/mediawiki-simple. The bundle_id is optional and
112 will be None if not given.
113+ In v4, the bundle ID is in the form ~user/bundlename, e.g.
114+ ~jorge/mediawiki-simple and is required.
115
116 Raise a ValueError if data represents an invalid request.
117 """
118@@ -92,6 +95,11 @@
119 bundles = yaml.safe_load(contents)
120 except Exception as err:
121 raise ValueError('invalid YAML contents: {}'.format(err))
122+ if params.get('Version') == 4:
123+ bundle_id = params.get('BundleID')
124+ return bundle_id, bundles, bundle_id
125+
126+ # This is an old-style bundle.
127 name = params.get('Name')
128 if name is None:
129 # The Name is optional if the YAML contents contain only one bundle.
130@@ -116,7 +124,12 @@
131 assigned to the bundle deployment.
132
133 Request: 'Import'.
134- Parameters example: {'Name': 'bundle-name', 'YAML': 'bundles'}.
135+ Parameters example: {
136+ 'Name': 'bundle-name',
137+ 'YAML': 'bundles',
138+ 'Version': 4,
139+ 'BundleID': '~user/bundle-name',
140+ }.
141 """
142 # Validate the request parameters.
143 try:
144@@ -130,7 +143,7 @@
145 error = 'invalid request: invalid bundle {}: {}'.format(name, err)
146 raise response(error=error)
147 # Validate the bundle against the current state of the Juju environment.
148- err = yield deployer.validate(request.user, name, bundle)
149+ err = yield deployer.validate(request.user, bundle)
150 if err is not None:
151 raise response(error='invalid request: {}'.format(err))
152 # Add the bundle deployment to the Deployer queue.
153
154=== modified file 'server/guiserver/tests/bundles/test_base.py'
155--- server/guiserver/tests/bundles/test_base.py 2014-12-17 13:19:51 +0000
156+++ server/guiserver/tests/bundles/test_base.py 2015-02-17 17:42:45 +0000
157@@ -86,7 +86,7 @@
158 # None is returned if the validation succeeds.
159 deployer = self.make_deployer()
160 with self.patch_validate():
161- result = yield deployer.validate(self.user, 'bundle', self.bundle)
162+ result = yield deployer.validate(self.user, self.bundle)
163 self.assertIsNone(result)
164
165 @gen_test
166@@ -95,7 +95,7 @@
167 deployer = self.make_deployer()
168 error = ValueError('validation error')
169 with self.patch_validate(side_effect=error):
170- result = yield deployer.validate(self.user, 'bundle', self.bundle)
171+ result = yield deployer.validate(self.user, self.bundle)
172 self.assertEqual(str(error), result)
173
174 @gen_test
175@@ -103,7 +103,7 @@
176 # The validation is executed in a separate process.
177 deployer = self.make_deployer()
178 with self.patch_validate() as mock_validate:
179- yield deployer.validate(self.user, 'bundle', self.bundle)
180+ yield deployer.validate(self.user, self.bundle)
181 mock_validate.assert_called_once_with(
182 self.apiurl, self.user.username, self.user.password, self.bundle)
183 mock_validate.assert_called_in_a_separate_process()
184@@ -112,7 +112,7 @@
185 def test_unsupported_api_version(self):
186 # An error message is returned the API version is not supported.
187 deployer = self.make_deployer(apiversion='not-supported')
188- result = yield deployer.validate(self.user, 'bundle', self.bundle)
189+ result = yield deployer.validate(self.user, self.bundle)
190 self.assertEqual('unsupported API version: not-supported', result)
191
192 def test_import_bundle_scheduling(self):
193@@ -422,7 +422,7 @@
194 self.deployment = base.DeployMiddleware(
195 self.user, self.deployer, self.responses.append)
196
197- def test_deployment_requested(self):
198+ def test_deployment_requested_v3(self):
199 # True is returned if the incoming data is a deployment request.
200 requests = (
201 self.make_deployment_request('Import'),
202@@ -434,7 +434,19 @@
203 requested = self.deployment.requested(request)
204 self.assertTrue(requested, request)
205
206- def test_deployment_not_requested(self):
207+ def test_deployment_requested_v4(self):
208+ # True is returned if the incoming data is a deployment request.
209+ requests = (
210+ self.make_deployment_request('Import', version=4),
211+ self.make_deployment_request('Watch', version=4),
212+ self.make_deployment_request('Next', version=4),
213+ self.make_deployment_request('Status', version=4),
214+ )
215+ for request in requests:
216+ requested = self.deployment.requested(request)
217+ self.assertTrue(requested, request)
218+
219+ def test_deployment_not_requested_v3(self):
220 # False is returned if the incoming data is not a deployment request.
221 # Params are not validated by DeployMiddleware.requested.
222 params = {'Name': 'mybundle', 'YAML': 'foo: bar'}
223@@ -474,8 +486,48 @@
224 requested = self.deployment.requested(request)
225 self.assertFalse(requested, request)
226
227+ def test_deployment_not_requested_v4(self):
228+ # False is returned if the incoming data is not a deployment request.
229+ # Params are not validated by DeployMiddleware.requested.
230+ params = {'Name': 'mybundle', 'YAML': 'foo: bar', 'Version': 4}
231+ requests = (
232+ # Empty request.
233+ {},
234+ # Invalid type field.
235+ {
236+ 'RequestId': 1,
237+ 'Type': 'INVALID',
238+ 'Request': 'Import',
239+ 'Params': params,
240+ },
241+ # Invalid request field.
242+ {
243+ 'RequestId': 2,
244+ 'Type': 'Deployer',
245+ 'Request': 'INVALID',
246+ 'Params': params,
247+ },
248+ # Missing request id field.
249+ {
250+ 'INVALID': 3,
251+ 'Type': 'Deployer',
252+ 'Request': 'Import',
253+ 'Params': params,
254+ },
255+ # Field names are case sensitive.
256+ {
257+ 'RequestId': 4,
258+ 'type': 'Deployer',
259+ 'request': 'Import',
260+ 'Params': params,
261+ },
262+ )
263+ for request in requests:
264+ requested = self.deployment.requested(request)
265+ self.assertFalse(requested, request)
266+
267 @gen_test
268- def test_process_request(self):
269+ def test_process_request_v3(self):
270 # A deployment request is correctly processed.
271 deployment_request = self.make_deployment_request('Import')
272
273@@ -495,3 +547,25 @@
274 self.assertEqual(1, len(self.responses))
275 response = self.responses[0]
276 self.assertEqual({'RequestId': 42, 'Response': 'ok'}, response)
277+
278+ @gen_test
279+ def test_process_request_v4(self):
280+ # A deployment request is correctly processed.
281+ deployment_request = self.make_deployment_request('Import', version=4)
282+
283+ @gen.coroutine
284+ def view(request, deployer):
285+ # Ensure the view is called with the expected arguments.
286+ self.assertEqual(deployment_request['Params'], request.params)
287+ self.assertIs(self.user, request.user)
288+ self.assertIs(self.deployer, deployer)
289+ return {'Response': 'ok'}
290+
291+ # Patch the routes so that the customized view defined above is called
292+ # when an import request is processed.
293+ self.deployment.routes['Import'] = view
294+ yield self.deployment.process_request(deployment_request)
295+ # Ensure the response has been correctly sent.
296+ self.assertEqual(1, len(self.responses))
297+ response = self.responses[0]
298+ self.assertEqual({'RequestId': 42, 'Response': 'ok'}, response)
299
300=== modified file 'server/guiserver/tests/bundles/test_views.py'
301--- server/guiserver/tests/bundles/test_views.py 2013-11-19 15:57:08 +0000
302+++ server/guiserver/tests/bundles/test_views.py 2015-02-17 17:42:45 +0000
303@@ -84,7 +84,7 @@
304 self.assertEqual(0, len(self.deployer.mock_calls))
305
306
307-class TestImportBundle(
308+class TestImportBundleV3(
309 ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
310 AsyncTestCase):
311
312@@ -189,7 +189,7 @@
313 self.assertEqual(expected_response, response)
314 # The Deployer validate method has been called.
315 self.deployer.validate.assert_called_once_with(
316- request.user, 'mybundle', {'services': {}})
317+ request.user, {'services': {}})
318
319 @gen_test
320 def test_success(self):
321@@ -204,7 +204,7 @@
322 expected_response = {'Response': {'DeploymentId': 42}}
323 self.assertEqual(expected_response, response)
324 # Ensure the Deployer methods have been correctly called.
325- args = (request.user, 'mybundle', {'services': {}})
326+ args = (request.user, {'services': {}})
327 self.deployer.validate.assert_called_once_with(*args)
328 args = (request.user, 'mybundle', {'services': {}}, None)
329 self.deployer.import_bundle.assert_called_once_with(*args)
330@@ -258,13 +258,149 @@
331 # Execute the view.
332 yield self.view(request, self.deployer)
333 # Ensure the Deployer methods have been correctly called.
334- args = (request.user, 'mybundle', {'services': {}})
335+ args = (request.user, {'services': {}})
336 self.deployer.validate.assert_called_once_with(*args)
337 args = (request.user, 'mybundle', {'services': {}},
338 '~jorge/wiki/3/smallwiki')
339 self.deployer.import_bundle.assert_called_once_with(*args)
340
341
342+class TestImportBundleV4(
343+ ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
344+ AsyncTestCase):
345+
346+ def get_view(self):
347+ return views.import_bundle
348+
349+ @gen_test
350+ def test_invalid_yaml(self):
351+ # An error response is returned if an invalid YAML encoded string is
352+ # passed.
353+ params = {'Name': 'bundle-name', 'Version': 4, 'YAML': 42}
354+ request = self.make_view_request(params=params)
355+ response = yield self.view(request, self.deployer)
356+ expected_response = {
357+ 'Response': {},
358+ 'Error': 'invalid request: invalid YAML contents: '
359+ "'int' object has no attribute 'read'",
360+ }
361+ self.assertEqual(expected_response, response)
362+ # The Deployer methods have not been called.
363+ self.assertEqual(0, len(self.deployer.mock_calls))
364+
365+ @gen_test
366+ def test_invalid_bundle(self):
367+ # An error response is returned if the bundle is not well formed.
368+ params = {'YAML': 'not valid', 'Version': 4, 'BundleID': 'foo'}
369+ request = self.make_view_request(params=params)
370+ response = yield self.view(request, self.deployer)
371+ expected_response = {
372+ 'Response': {},
373+ 'Error': 'invalid request: invalid bundle foo: '
374+ 'the bundle data is not well formed',
375+ }
376+ self.assertEqual(expected_response, response)
377+ # The Deployer methods have not been called.
378+ self.assertEqual(0, len(self.deployer.mock_calls))
379+
380+ @gen_test
381+ def test_invalid_bundle_constraints(self):
382+ # An error response is returned if the bundle includes services with
383+ # unsupported constraints.
384+ params = {
385+ 'YAML': 'services: {django: {constraints: invalid=1}}',
386+ 'Version': 4,
387+ 'BundleID': 'foo'
388+ }
389+ request = self.make_view_request(params=params)
390+ response = yield self.view(request, self.deployer)
391+ expected_response = {
392+ 'Response': {},
393+ 'Error': 'invalid request: invalid bundle foo: '
394+ 'unsupported constraints: invalid',
395+ }
396+ self.assertEqual(expected_response, response)
397+ # The Deployer methods have not been called.
398+ self.assertEqual(0, len(self.deployer.mock_calls))
399+
400+ @gen_test
401+ def test_undeployable_bundle(self):
402+ # An error response is returned if the bundle cannot be imported in the
403+ # current Juju environment.
404+ params = {'Version': 4, 'YAML': 'services: {}'}
405+ request = self.make_view_request(params=params)
406+ # Simulate an error returned by the Deployer validate method.
407+ self.deployer.validate.return_value = self.make_future('an error')
408+ # Execute the view.
409+ response = yield self.view(request, self.deployer)
410+ expected_response = {
411+ 'Response': {},
412+ 'Error': 'invalid request: an error',
413+ }
414+ self.assertEqual(expected_response, response)
415+ # The Deployer validate method has been called.
416+ self.deployer.validate.assert_called_once_with(
417+ request.user, {'services': {}})
418+
419+ @gen_test
420+ def test_success(self):
421+ # The response includes the deployment identifier.
422+ params = {'BundleID': 'foo', 'Version': 4, 'YAML': 'services: {}'}
423+ request = self.make_view_request(params=params)
424+ # Set up the Deployer mock.
425+ self.deployer.validate.return_value = self.make_future(None)
426+ self.deployer.import_bundle.return_value = 42
427+ # Execute the view.
428+ response = yield self.view(request, self.deployer)
429+ expected_response = {'Response': {'DeploymentId': 42}}
430+ self.assertEqual(expected_response, response)
431+ # Ensure the Deployer methods have been correctly called.
432+ args = (request.user, {'services': {}})
433+ self.deployer.validate.assert_called_once_with(*args)
434+ args = (request.user, 'foo', {'services': {}}, 'foo')
435+ self.deployer.import_bundle.assert_called_once_with(*args)
436+
437+ @gen_test
438+ def test_logging(self):
439+ # The beginning of the bundle import process is properly logged.
440+ params = {'BundleID': 'foo', 'Version': 4, 'YAML': 'services: {}'}
441+ request = self.make_view_request(params=params)
442+ # Set up the Deployer mock.
443+ self.deployer.validate.return_value = self.make_future(None)
444+ self.deployer.import_bundle.return_value = 42
445+ # Execute the view.
446+ expected_log = "import_bundle: scheduling 'foo' deployment"
447+ with ExpectLog('', expected_log, required=True):
448+ yield self.view(request, self.deployer)
449+
450+ # The following tests exercise views._validate_import_params directly.
451+ def test_id_provided(self):
452+ params = {'YAML': 'services: {}',
453+ 'Version': 4,
454+ 'BundleID': '~jorge/wiki'}
455+ results = views._validate_import_params(params)
456+ expected = ('~jorge/wiki', {'services': {}}, '~jorge/wiki')
457+ self.assertEqual(expected, results)
458+
459+ @gen_test
460+ def test_id_passed_to_deployer(self):
461+ params = {'YAML': 'services: {}',
462+ 'Version': 4,
463+ 'BundleID': '~jorge/wiki/3/smallwiki'}
464+ request = self.make_view_request(params=params)
465+ # Set up the Deployer mock.
466+ self.deployer.validate.return_value = self.make_future(None)
467+ self.deployer.import_bundle.return_value = 42
468+ # Execute the view.
469+ yield self.view(request, self.deployer)
470+ # Ensure the Deployer methods have been correctly called.
471+ args = (request.user, {'services': {}})
472+ self.deployer.validate.assert_called_once_with(*args)
473+ args = (request.user, '~jorge/wiki/3/smallwiki', {'services': {}},
474+ '~jorge/wiki/3/smallwiki')
475+ self.deployer.import_bundle.assert_called_once_with(*args)
476+
477+
478 class TestWatch(
479 ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
480 AsyncTestCase):
481
482=== modified file 'server/guiserver/tests/helpers.py'
483--- server/guiserver/tests/helpers.py 2014-01-28 15:48:59 +0000
484+++ server/guiserver/tests/helpers.py 2015-02-17 17:42:45 +0000
485@@ -143,19 +143,26 @@
486 return mock.Mock(params=params, user=user)
487
488 def make_deployment_request(
489- self, request, request_id=42, params=None, encoded=False):
490+ self, request, request_id=42, params=None, encoded=False,
491+ version=None):
492 """Create and return a deployment request message.
493
494 If encoded is set to True, the returned message will be JSON encoded.
495 """
496+ if version is None:
497+ bundle = 'bundle: {services: {}}'
498+ else:
499+ bundle = 'services: {}'
500 defaults = {
501- 'Import': {'Name': 'bundle', 'YAML': 'bundle: {services: {}}'},
502+ 'Import': {'Name': 'bundle', 'YAML': bundle},
503 'Watch': {'DeploymentId': 0},
504 'Next': {'WatcherId': 0},
505 'Status': {},
506 }
507 if params is None:
508 params = defaults[request]
509+ if version is not None:
510+ params['Version'] = version
511 data = {
512 'RequestId': request_id,
513 'Type': 'Deployer',

Subscribers

People subscribed via source and target branches