Merge lp:~frankban/charms/precise/juju-gui/guiserver-views into lp:~juju-gui/charms/precise/juju-gui/trunk
- Precise Pangolin (12.04)
- guiserver-views
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 93 |
Proposed branch: | lp:~frankban/charms/precise/juju-gui/guiserver-views |
Merge into: | lp:~juju-gui/charms/precise/juju-gui/trunk |
Diff against target: |
671 lines (+616/-2) 7 files modified
revision (+1/-1) server/guiserver/bundles/__init__.py (+2/-1) server/guiserver/bundles/utils.py (+54/-0) server/guiserver/bundles/views.py (+177/-0) server/guiserver/tests/bundles/test_utils.py (+108/-0) server/guiserver/tests/bundles/test_views.py (+259/-0) server/guiserver/tests/helpers.py (+15/-0) |
To merge this branch: | bzr merge lp:~frankban/charms/precise/juju-gui/guiserver-views |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
charmers | Pending | ||
Review via email:
|
Commit message
Description of the change
Bundle deployment views.
This branch includes the implementation of the
bundle views. Their goal is to handle the bundle
deployment request/response process.
Views interact with the Deployer instance
(not yet implemented) in order to schedule,
run and observe bundle deployments.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Brad Crittenden (bac) wrote : | # |
LGTM. Nice tests and well-organized helpers / factoring.
https:/
File server/
https:/
server/
access: unknown user')
The error message is a little misleading. "unknown user" implies we
have a user but don't recognize her when in fact there is no logged in
user.
How about:
error='unauthorized access: no user logged in.' ?
https:/
File server/
https:/
server/
s/creating this kind of responses/create these responses
https:/
server/
request: invalid data parameters')
Perhaps list the params in the error message?
- 109. By Francesco Banconi
-
Changes as per review.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
*** Submitted:
Bundle deployment views.
This branch includes the implementation of the
bundle views. Their goal is to handle the bundle
deployment request/response process.
Views interact with the Deployer instance
(not yet implemented) in order to schedule,
run and observe bundle deployments.
R=bac, benji
CC=
https:/
https:/
File server/
https:/
server/
access: unknown user')
On 2013/08/20 13:24:09, bac wrote:
> The error message is a little misleading. "unknown user" implies we
have a user
> but don't recognize her when in fact there is no logged in user.
> How about:
> error='unauthorized access: no user logged in.' ?
You are right, changed the message.
https:/
File server/
https:/
server/
On 2013/08/20 13:24:09, bac wrote:
> s/creating this kind of responses/create these responses
Done.
https:/
server/
fixed switching to a newer version of Python).
On 2013/08/20 13:28:10, benji wrote:
> I was wondering about why we didn't use Python 3 for this server. Is
there a
> Tornado requirement for Python 2?
No, Tornado is fully compatible with Python3. And I guess it should be
quite easy to port the GUI server to Python3. The problem here is that
juju-deployer only supports Python2, and therefore, for now, we cannot
upgrade.
https:/
server/
unseen changes are available, a response is
On 2013/08/20 13:28:10, benji wrote:
> "unsent" might be slightly clearer than "unseen".
Done.
https:/
server/
changes. Otherwise, this views suspends
On 2013/08/20 13:28:10, benji wrote:
> I think "immediately" would be a bit better than "suddenly".
Done.
https:/
server/
request: invalid data parameters')
On 2013/08/20 13:24:09, bac wrote:
> Perhaps list the params in the error message?
Done.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
Thank you both for the reviews.
Preview Diff
1 | === modified file 'revision' |
2 | --- revision 2013-08-19 08:50:17 +0000 |
3 | +++ revision 2013-08-20 14:10:33 +0000 |
4 | @@ -1,1 +1,1 @@ |
5 | -71 |
6 | +72 |
7 | |
8 | === modified file 'server/guiserver/bundles/__init__.py' |
9 | --- server/guiserver/bundles/__init__.py 2013-08-19 08:41:44 +0000 |
10 | +++ server/guiserver/bundles/__init__.py 2013-08-20 14:10:33 +0000 |
11 | @@ -27,7 +27,8 @@ |
12 | - validate(user, name, bundle) -> Future (str or None); |
13 | - import_bundle(user, name, bundle) -> int (a deployment id); |
14 | - watch(deployment_id) -> int or None (a watcher id); |
15 | - - next(watcher_id) -> Future (changes or None). |
16 | + - next(watcher_id) -> Future (changes or None); |
17 | + - status() -> list (of changes). |
18 | |
19 | The following arguments are passed to the validate and import_bundle |
20 | interface methods: |
21 | |
22 | === added file 'server/guiserver/bundles/utils.py' |
23 | --- server/guiserver/bundles/utils.py 1970-01-01 00:00:00 +0000 |
24 | +++ server/guiserver/bundles/utils.py 2013-08-20 14:10:33 +0000 |
25 | @@ -0,0 +1,54 @@ |
26 | +# This file is part of the Juju GUI, which lets users view and manage Juju |
27 | +# environments within a graphical interface (https://launchpad.net/juju-gui). |
28 | +# Copyright (C) 2013 Canonical Ltd. |
29 | +# |
30 | +# This program is free software: you can redistribute it and/or modify it under |
31 | +# the terms of the GNU Affero General Public License version 3, as published by |
32 | +# the Free Software Foundation. |
33 | +# |
34 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
35 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
36 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
37 | +# Affero General Public License for more details. |
38 | +# |
39 | +# You should have received a copy of the GNU Affero General Public License |
40 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
41 | + |
42 | +"""Bundle deployment utility functions.""" |
43 | + |
44 | +from functools import wraps |
45 | +import logging |
46 | + |
47 | +from tornado import gen |
48 | + |
49 | + |
50 | +def require_authenticated_user(view): |
51 | + """Require the user to be authenticated when executing the decorated view. |
52 | + |
53 | + This function can be used to decorate bundle views. Each view receives |
54 | + a request and a deployer, and the user instance is stored in request.user. |
55 | + If the user is not authenticated an error response is raised when calling |
56 | + the view. Otherwise, the view is executed normally. |
57 | + """ |
58 | + @wraps(view) |
59 | + def decorated(request, deployer): |
60 | + if not request.user.is_authenticated: |
61 | + raise response(error='unauthorized access: no user logged in') |
62 | + return view(request, deployer) |
63 | + return decorated |
64 | + |
65 | + |
66 | +def response(info=None, error=None): |
67 | + """Create a response containing the given (optional) info and error values. |
68 | + |
69 | + This function is intended to be used by bundles views. |
70 | + Return a gen.Return instance, so that the result of this method can easily |
71 | + be raised from coroutines. |
72 | + """ |
73 | + if info is None: |
74 | + info = {} |
75 | + data = {'Response': info} |
76 | + if error is not None: |
77 | + logging.error('deployer: {}'.format(error)) |
78 | + data['Error'] = error |
79 | + return gen.Return(data) |
80 | |
81 | === added file 'server/guiserver/bundles/views.py' |
82 | --- server/guiserver/bundles/views.py 1970-01-01 00:00:00 +0000 |
83 | +++ server/guiserver/bundles/views.py 2013-08-20 14:10:33 +0000 |
84 | @@ -0,0 +1,177 @@ |
85 | +# This file is part of the Juju GUI, which lets users view and manage Juju |
86 | +# environments within a graphical interface (https://launchpad.net/juju-gui). |
87 | +# Copyright (C) 2013 Canonical Ltd. |
88 | +# |
89 | +# This program is free software: you can redistribute it and/or modify it under |
90 | +# the terms of the GNU Affero General Public License version 3, as published by |
91 | +# the Free Software Foundation. |
92 | +# |
93 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
94 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
95 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
96 | +# Affero General Public License for more details. |
97 | +# |
98 | +# You should have received a copy of the GNU Affero General Public License |
99 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
100 | + |
101 | +"""Bundle deployment views. |
102 | + |
103 | +This module includes the views used to create responses for bundle deployments |
104 | +related requests. The bundles protocol, described in the bundles package |
105 | +docstring, mimics the request/response paradigm over a WebSocket. Views are |
106 | +simple functions that, given a request, return a response to be sent back to |
107 | +the API client. Each view receives the following arguments: |
108 | + |
109 | + - request: a request object with two attributes: |
110 | + - request.params: a dict representing the parameters sent by the client; |
111 | + - request.user: the current user (an instance of guiserver.auth.User); |
112 | + - deployer: a Deployer instance, ready to be used to schedule/start/observe |
113 | + bundle deployments. |
114 | + |
115 | +The response returned by views must be a Future containing the response data as |
116 | +a dict-like object, e.g.: |
117 | + |
118 | + {'Response': {}, 'Error': 'this field is optional'} |
119 | + |
120 | +The response function defined in the guiserver.bundles.utils module helps |
121 | +create these responses: |
122 | + |
123 | + from guiserver.bundles.utils import response |
124 | + |
125 | + @gen.coroutine |
126 | + def succeeding_view(request, deployer) |
127 | + raise response('Success!') |
128 | + |
129 | + @gen.coroutine |
130 | + def failing_view(request, deployer) |
131 | + raise response(error='Boo!') |
132 | + |
133 | +Use the require_authenticated_user decorator if the view requires a logged in |
134 | +user, e.g.: |
135 | + |
136 | + @gen.coroutine |
137 | + @require_authenticated_user |
138 | + def protected_view(request, deployer): |
139 | + # This function body is executed only if the user is authenticated. |
140 | + |
141 | +As seen in the examples above, views are also coroutines: they must be |
142 | +decorated with tornado.gen.coroutine, they can suspend their own execution |
143 | +using "yield", and they must return their results using "raise response(...)" |
144 | +(the latter will be eventually fixed switching to a newer version of Python). |
145 | +""" |
146 | + |
147 | +from tornado import gen |
148 | +import yaml |
149 | + |
150 | +from guiserver.bundles.utils import ( |
151 | + require_authenticated_user, |
152 | + response, |
153 | +) |
154 | + |
155 | + |
156 | +def _validate_import_params(params): |
157 | + """Parse the request data and return a (name, bundle) tuple. |
158 | + |
159 | + In the tuple: |
160 | + - name is the name of the bundle to be imported; |
161 | + - bundle is the YAML decoded bundle object. |
162 | + |
163 | + Raise a ValueError if data represents an invalid request. |
164 | + """ |
165 | + name = params.get('Name') |
166 | + contents = params.get('YAML') |
167 | + if not (name and contents): |
168 | + raise ValueError('invalid data parameters') |
169 | + try: |
170 | + bundles = yaml.load(contents, Loader=yaml.SafeLoader) |
171 | + except Exception as err: |
172 | + raise ValueError('invalid YAML contents: {}'.format(err)) |
173 | + bundle = bundles.get(name) |
174 | + if bundle is None: |
175 | + raise ValueError('bundle {} not found'.format(name)) |
176 | + return name, bundle |
177 | + |
178 | + |
179 | +@gen.coroutine |
180 | +@require_authenticated_user |
181 | +def import_bundle(request, deployer): |
182 | + """Start or schedule a bundle deployment. |
183 | + |
184 | + If the request is valid, the response will contain the DeploymentId |
185 | + assigned to the bundle deployment. |
186 | + |
187 | + Request: 'Import'. |
188 | + Parameters example: {'Name': 'bundle-name', 'YAML': 'bundles'}. |
189 | + """ |
190 | + # Validate the request parameters. |
191 | + try: |
192 | + name, bundle = _validate_import_params(request.params) |
193 | + except ValueError as err: |
194 | + raise response(error='invalid request: {}'.format(err)) |
195 | + # Validate the bundle against the current state of the Juju environment. |
196 | + err = yield deployer.validate(request.user, name, bundle) |
197 | + if err is not None: |
198 | + raise response(error='invalid request: {}'.format(err)) |
199 | + # Add the bundle deployment to the Deployer queue. |
200 | + deployment_id = deployer.import_bundle(request.user, name, bundle) |
201 | + raise response({'DeploymentId': deployment_id}) |
202 | + |
203 | + |
204 | +@gen.coroutine |
205 | +@require_authenticated_user |
206 | +def watch(request, deployer): |
207 | + """Handle requests for watching a given deployment. |
208 | + |
209 | + The deployment is identified in the request by the DeploymentId parameter. |
210 | + If the request is valid, the response will contain the WatcherId |
211 | + to be used to observe the deployment progress. |
212 | + |
213 | + Request: 'Watch'. |
214 | + Parameters example: {'DeploymentId': 42}. |
215 | + """ |
216 | + deployment_id = request.params.get('DeploymentId') |
217 | + if deployment_id is None: |
218 | + raise response(error='invalid request: invalid data parameters') |
219 | + # Retrieve a watcher identifier from the Deployer. |
220 | + watcher_id = deployer.watch(deployment_id) |
221 | + if watcher_id is None: |
222 | + raise response(error='invalid request: deployment not found') |
223 | + raise response({'WatcherId': watcher_id}) |
224 | + |
225 | + |
226 | +@gen.coroutine |
227 | +@require_authenticated_user |
228 | +def next(request, deployer): |
229 | + """Wait until a new deployment event is available to be sent to the client. |
230 | + |
231 | + The request params must include a WatcherId value, used to identify the |
232 | + deployment being observed. If unsent changes are available, a response is |
233 | + immediately returned containing the changes. Otherwise, this views suspends |
234 | + its execution until a new change is notified by the Deployer. |
235 | + |
236 | + Request: 'Next'. |
237 | + Parameters example: {'WatcherId': 47}. |
238 | + """ |
239 | + watcher_id = request.params.get('WatcherId') |
240 | + if watcher_id is None: |
241 | + raise response(error='invalid request: invalid data parameters') |
242 | + # Wait for the Deployer to send changes. |
243 | + changes = yield deployer.next(watcher_id) |
244 | + if changes is None: |
245 | + raise response(error='invalid request: invalid watcher identifier') |
246 | + raise response({'Changes': changes}) |
247 | + |
248 | + |
249 | +@gen.coroutine |
250 | +@require_authenticated_user |
251 | +def status(request, deployer): |
252 | + """Return the current status of all the bundle deployments. |
253 | + |
254 | + The 'Status' request does not receive parameters. |
255 | + """ |
256 | + if request.params: |
257 | + params = ', '.join(request.params) |
258 | + error = 'invalid request: invalid data parameters: {}'.format(params) |
259 | + raise response(error=error) |
260 | + last_changes = deployer.status() |
261 | + raise response({'LastChanges': last_changes}) |
262 | |
263 | === added file 'server/guiserver/tests/bundles/test_utils.py' |
264 | --- server/guiserver/tests/bundles/test_utils.py 1970-01-01 00:00:00 +0000 |
265 | +++ server/guiserver/tests/bundles/test_utils.py 2013-08-20 14:10:33 +0000 |
266 | @@ -0,0 +1,108 @@ |
267 | +# This file is part of the Juju GUI, which lets users view and manage Juju |
268 | +# environments within a graphical interface (https://launchpad.net/juju-gui). |
269 | +# Copyright (C) 2013 Canonical Ltd. |
270 | +# |
271 | +# This program is free software: you can redistribute it and/or modify it under |
272 | +# the terms of the GNU Affero General Public License version 3, as published by |
273 | +# the Free Software Foundation. |
274 | +# |
275 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
276 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
277 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
278 | +# Affero General Public License for more details. |
279 | +# |
280 | +# You should have received a copy of the GNU Affero General Public License |
281 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
282 | + |
283 | +"""Tests for the deployment utility functions.""" |
284 | + |
285 | +import unittest |
286 | + |
287 | +from tornado import gen |
288 | +from tornado.testing import( |
289 | + AsyncTestCase, |
290 | + ExpectLog, |
291 | + gen_test, |
292 | + LogTrapTestCase, |
293 | +) |
294 | + |
295 | +from guiserver.bundles import utils |
296 | +from guiserver.tests import helpers |
297 | + |
298 | + |
299 | +class TestRequireAuthenticatedUser( |
300 | + helpers.BundlesTestMixin, LogTrapTestCase, AsyncTestCase): |
301 | + |
302 | + deployer = 'fake-deployer' |
303 | + |
304 | + def make_view(self): |
305 | + """Return a view to be used for tests. |
306 | + |
307 | + The resulting callable must be called with a request object as first |
308 | + argument and with self.deployer as second argument. |
309 | + """ |
310 | + @gen.coroutine |
311 | + @utils.require_authenticated_user |
312 | + def myview(request, deployer): |
313 | + """An example testing view.""" |
314 | + self.assertEqual(self.deployer, deployer) |
315 | + raise utils.response(info='ok') |
316 | + return myview |
317 | + |
318 | + @gen_test |
319 | + def test_authenticated(self): |
320 | + # The view is executed normally if the user is authenticated. |
321 | + view = self.make_view() |
322 | + request = self.make_view_request(is_authenticated=True) |
323 | + response = yield view(request, self.deployer) |
324 | + self.assertEqual({'Response': 'ok'}, response) |
325 | + |
326 | + @gen_test |
327 | + def test_not_authenticated(self): |
328 | + # The view returns an error response if the user is not authenticated. |
329 | + view = self.make_view() |
330 | + request = self.make_view_request(is_authenticated=False) |
331 | + response = yield view(request, self.deployer) |
332 | + expected = { |
333 | + 'Response': {}, |
334 | + 'Error': 'unauthorized access: no user logged in', |
335 | + } |
336 | + self.assertEqual(expected, response) |
337 | + |
338 | + def test_wrap(self): |
339 | + # The decorated view looks like the wrapped function. |
340 | + view = self.make_view() |
341 | + self.assertEqual('myview', view.__name__) |
342 | + self.assertEqual('An example testing view.', view.__doc__) |
343 | + |
344 | + |
345 | +class TestResponse(LogTrapTestCase, unittest.TestCase): |
346 | + |
347 | + def assert_response(self, expected, response): |
348 | + """Ensure the given gen.Return instance contains the expected response. |
349 | + """ |
350 | + self.assertIsInstance(response, gen.Return) |
351 | + self.assertEqual(expected, response.value) |
352 | + |
353 | + def test_empty(self): |
354 | + # An empty response is correctly generated. |
355 | + expected = {'Response': {}} |
356 | + response = utils.response() |
357 | + self.assert_response(expected, response) |
358 | + |
359 | + def test_success(self): |
360 | + # A success response is correctly generated. |
361 | + expected = {'Response': {'foo': 'bar'}} |
362 | + response = utils.response({'foo': 'bar'}) |
363 | + self.assert_response(expected, response) |
364 | + |
365 | + def test_failure(self): |
366 | + # A failure response is correctly generated. |
367 | + expected = {'Error': 'an error occurred', 'Response': {}} |
368 | + response = utils.response(error='an error occurred') |
369 | + self.assert_response(expected, response) |
370 | + |
371 | + def test_log_failure(self): |
372 | + # An error log is written when a failure response is generated. |
373 | + with ExpectLog('', 'deployer: an error occurred', required=True): |
374 | + utils.response(error='an error occurred') |
375 | |
376 | === added file 'server/guiserver/tests/bundles/test_views.py' |
377 | --- server/guiserver/tests/bundles/test_views.py 1970-01-01 00:00:00 +0000 |
378 | +++ server/guiserver/tests/bundles/test_views.py 2013-08-20 14:10:33 +0000 |
379 | @@ -0,0 +1,259 @@ |
380 | +# This file is part of the Juju GUI, which lets users view and manage Juju |
381 | +# environments within a graphical interface (https://launchpad.net/juju-gui). |
382 | +# Copyright (C) 2013 Canonical Ltd. |
383 | +# |
384 | +# This program is free software: you can redistribute it and/or modify it under |
385 | +# the terms of the GNU Affero General Public License version 3, as published by |
386 | +# the Free Software Foundation. |
387 | +# |
388 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
389 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
390 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
391 | +# Affero General Public License for more details. |
392 | +# |
393 | +# You should have received a copy of the GNU Affero General Public License |
394 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
395 | + |
396 | +"""Tests for the bundle deployment views.""" |
397 | + |
398 | +import mock |
399 | +from tornado import concurrent |
400 | +from tornado.testing import( |
401 | + AsyncTestCase, |
402 | + ExpectLog, |
403 | + gen_test, |
404 | + LogTrapTestCase, |
405 | +) |
406 | + |
407 | +from guiserver.bundles import views |
408 | +from guiserver.tests import helpers |
409 | + |
410 | + |
411 | +class ViewsTestMixin(object): |
412 | + """Base helpers and common tests for all the view tests. |
413 | + |
414 | + Subclasses must define a get_view() method returning the view function to |
415 | + be tested. Subclasses can also override the invalid_params and |
416 | + invalid_params_error attributes, used to test the view in the case the |
417 | + passed parameters are not valid. |
418 | + """ |
419 | + |
420 | + invalid_params = {'No-such': 'parameter'} |
421 | + invalid_params_error = 'invalid request: invalid data parameters' |
422 | + |
423 | + def setUp(self): |
424 | + super(ViewsTestMixin, self).setUp() |
425 | + self.view = self.get_view() |
426 | + self.deployer = mock.Mock() |
427 | + |
428 | + def make_future(self, result): |
429 | + """Create and return a Future containing the given result.""" |
430 | + future = concurrent.Future() |
431 | + future.set_result(result) |
432 | + return future |
433 | + |
434 | + @gen_test |
435 | + def test_not_authenticated(self): |
436 | + # An error response is returned if the user is not authenticated. |
437 | + request = self.make_view_request(is_authenticated=False) |
438 | + expected_log = 'deployer: unauthorized access: no user logged in' |
439 | + with ExpectLog('', expected_log, required=True): |
440 | + response = yield self.view(request, self.deployer) |
441 | + expected_response = { |
442 | + 'Response': {}, |
443 | + 'Error': 'unauthorized access: no user logged in', |
444 | + } |
445 | + self.assertEqual(expected_response, response) |
446 | + # The Deployer methods have not been called. |
447 | + self.assertEqual(0, len(self.deployer.mock_calls)) |
448 | + |
449 | + @gen_test |
450 | + def test_invalid_parameters(self): |
451 | + # An error response is returned if the parameters in the request are |
452 | + # not valid. |
453 | + request = self.make_view_request(params=self.invalid_params) |
454 | + expected_log = 'deployer: {}'.format(self.invalid_params_error) |
455 | + with ExpectLog('', expected_log, required=True): |
456 | + response = yield self.view(request, self.deployer) |
457 | + expected_response = { |
458 | + 'Response': {}, |
459 | + 'Error': self.invalid_params_error, |
460 | + } |
461 | + self.assertEqual(expected_response, response) |
462 | + # The Deployer methods have not been called. |
463 | + self.assertEqual(0, len(self.deployer.mock_calls)) |
464 | + |
465 | + |
466 | +class TestImportBundle( |
467 | + ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, |
468 | + AsyncTestCase): |
469 | + |
470 | + def get_view(self): |
471 | + return views.import_bundle |
472 | + |
473 | + @gen_test |
474 | + def test_invalid_yaml(self): |
475 | + # An error response is returned if an invalid YAML encoded string is |
476 | + # passed. |
477 | + params = {'Name': 'bundle-name', 'YAML': 42} |
478 | + request = self.make_view_request(params=params) |
479 | + response = yield self.view(request, self.deployer) |
480 | + expected_response = { |
481 | + 'Response': {}, |
482 | + 'Error': 'invalid request: invalid YAML contents: ' |
483 | + "'int' object has no attribute 'read'", |
484 | + } |
485 | + self.assertEqual(expected_response, response) |
486 | + # The Deployer methods have not been called. |
487 | + self.assertEqual(0, len(self.deployer.mock_calls)) |
488 | + |
489 | + @gen_test |
490 | + def test_bundle_not_found(self): |
491 | + # An error response is returned if the requested bundle name is not |
492 | + # found in the bundle YAML contents. |
493 | + params = {'Name': 'no-such-bundle', 'YAML': 'mybundle: mycontents'} |
494 | + request = self.make_view_request(params=params) |
495 | + response = yield self.view(request, self.deployer) |
496 | + expected_response = { |
497 | + 'Response': {}, |
498 | + 'Error': 'invalid request: bundle no-such-bundle not found', |
499 | + } |
500 | + self.assertEqual(expected_response, response) |
501 | + # The Deployer methods have not been called. |
502 | + self.assertEqual(0, len(self.deployer.mock_calls)) |
503 | + |
504 | + @gen_test |
505 | + def test_invalid_bundle(self): |
506 | + # An error response is returned if the bundle cannot be imported in the |
507 | + # current Juju environment. |
508 | + params = {'Name': 'mybundle', 'YAML': 'mybundle: mycontents'} |
509 | + request = self.make_view_request(params=params) |
510 | + # Simulate an error returned by the Deployer validate method. |
511 | + self.deployer.validate.return_value = self.make_future('an error') |
512 | + # Execute the view. |
513 | + response = yield self.view(request, self.deployer) |
514 | + expected_response = { |
515 | + 'Response': {}, |
516 | + 'Error': 'invalid request: an error', |
517 | + } |
518 | + self.assertEqual(expected_response, response) |
519 | + # The Deployer validate method has been called. |
520 | + self.deployer.validate.assert_called_once_with( |
521 | + request.user, 'mybundle', 'mycontents') |
522 | + |
523 | + @gen_test |
524 | + def test_success(self): |
525 | + # The response includes the deployment identifier. |
526 | + params = {'Name': 'mybundle', 'YAML': 'mybundle: mycontents'} |
527 | + request = self.make_view_request(params=params) |
528 | + # Set up the Deployer mock. |
529 | + self.deployer.validate.return_value = self.make_future(None) |
530 | + self.deployer.import_bundle.return_value = 42 |
531 | + # Execute the view. |
532 | + response = yield self.view(request, self.deployer) |
533 | + expected_response = {'Response': {'DeploymentId': 42}} |
534 | + self.assertEqual(expected_response, response) |
535 | + # Ensure the Deployer methods have been correctly called. |
536 | + args = (request.user, 'mybundle', 'mycontents') |
537 | + self.deployer.validate.assert_called_once_with(*args) |
538 | + self.deployer.import_bundle.assert_called_once_with(*args) |
539 | + |
540 | + |
541 | +class TestWatch( |
542 | + ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, |
543 | + AsyncTestCase): |
544 | + |
545 | + def get_view(self): |
546 | + return views.watch |
547 | + |
548 | + @gen_test |
549 | + def test_deployment_not_found(self): |
550 | + # An error response is returned if the deployment identifier is not |
551 | + # valid. |
552 | + request = self.make_view_request(params={'DeploymentId': 42}) |
553 | + # Set up the Deployer mock. |
554 | + self.deployer.watch.return_value = None |
555 | + # Execute the view. |
556 | + response = yield self.view(request, self.deployer) |
557 | + expected_response = { |
558 | + 'Response': {}, |
559 | + 'Error': 'invalid request: deployment not found', |
560 | + } |
561 | + self.assertEqual(expected_response, response) |
562 | + # Ensure the Deployer methods have been correctly called. |
563 | + self.deployer.watch.assert_called_once_with(42) |
564 | + |
565 | + @gen_test |
566 | + def test_success(self): |
567 | + # The response includes the watcher identifier. |
568 | + request = self.make_view_request(params={'DeploymentId': 42}) |
569 | + # Set up the Deployer mock. |
570 | + self.deployer.watch.return_value = 47 |
571 | + # Execute the view. |
572 | + response = yield self.view(request, self.deployer) |
573 | + expected_response = {'Response': {'WatcherId': 47}} |
574 | + self.assertEqual(expected_response, response) |
575 | + # Ensure the Deployer methods have been correctly called. |
576 | + self.deployer.watch.assert_called_once_with(42) |
577 | + |
578 | + |
579 | +class TestNext( |
580 | + ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, |
581 | + AsyncTestCase): |
582 | + |
583 | + def get_view(self): |
584 | + return views.next |
585 | + |
586 | + @gen_test |
587 | + def test_invalid_watcher_identifier(self): |
588 | + # An error response is returned if the watcher identifier is not valid. |
589 | + request = self.make_view_request(params={'WatcherId': 42}) |
590 | + # Set up the Deployer mock. |
591 | + self.deployer.next.return_value = self.make_future(None) |
592 | + # Execute the view. |
593 | + response = yield self.view(request, self.deployer) |
594 | + expected_response = { |
595 | + 'Response': {}, |
596 | + 'Error': 'invalid request: invalid watcher identifier', |
597 | + } |
598 | + self.assertEqual(expected_response, response) |
599 | + # Ensure the Deployer methods have been correctly called. |
600 | + self.deployer.next.assert_called_once_with(42) |
601 | + |
602 | + @gen_test |
603 | + def test_success(self): |
604 | + # The response includes the deployment changes. |
605 | + request = self.make_view_request(params={'WatcherId': 42}) |
606 | + # Set up the Deployer mock. |
607 | + changes = ['change1', 'change2'] |
608 | + self.deployer.next.return_value = self.make_future(changes) |
609 | + # Execute the view. |
610 | + response = yield self.view(request, self.deployer) |
611 | + expected_response = {'Response': {'Changes': changes}} |
612 | + self.assertEqual(expected_response, response) |
613 | + # Ensure the Deployer methods have been correctly called. |
614 | + self.deployer.next.assert_called_once_with(42) |
615 | + |
616 | + |
617 | +class TestStatus( |
618 | + ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, |
619 | + AsyncTestCase): |
620 | + |
621 | + invalid_params_error = 'invalid request: invalid data parameters: No-such' |
622 | + |
623 | + def get_view(self): |
624 | + return views.status |
625 | + |
626 | + @gen_test |
627 | + def test_success(self): |
628 | + # The response includes the watcher identifier. |
629 | + request = self.make_view_request() |
630 | + # Set up the Deployer mock. |
631 | + last_changes = ['change1', 'change2'] |
632 | + self.deployer.status.return_value = last_changes |
633 | + # Execute the view. |
634 | + response = yield self.view(request, self.deployer) |
635 | + expected_response = {'Response': {'LastChanges': last_changes}} |
636 | + self.assertEqual(expected_response, response) |
637 | + # Ensure the Deployer methods have been correctly called. |
638 | + self.deployer.status.assert_called_once_with() |
639 | |
640 | === modified file 'server/guiserver/tests/helpers.py' |
641 | --- server/guiserver/tests/helpers.py 2013-08-19 13:56:31 +0000 |
642 | +++ server/guiserver/tests/helpers.py 2013-08-20 14:10:33 +0000 |
643 | @@ -18,6 +18,7 @@ |
644 | |
645 | import json |
646 | |
647 | +import mock |
648 | from tornado import websocket |
649 | import yaml |
650 | |
651 | @@ -179,6 +180,20 @@ |
652 | all_contents = yaml.load(self.bundle) |
653 | return all_contents.items()[0] |
654 | |
655 | + def make_view_request(self, params=None, is_authenticated=True): |
656 | + """Create and return a mock request to be passed to bundle views. |
657 | + |
658 | + The resulting request contains the given parameters and a |
659 | + guiserver.auth.User instance. |
660 | + If is_authenticated is True, the user in the request is logged in. |
661 | + """ |
662 | + if params is None: |
663 | + params = {} |
664 | + user = auth.User( |
665 | + username='user', password='passwd', |
666 | + is_authenticated=is_authenticated) |
667 | + return mock.Mock(params=params, user=user) |
668 | + |
669 | |
670 | class WSSTestMixin(object): |
671 | """Add some helper methods for testing secure WebSocket handlers.""" |
Reviewers: mp+181020_ code.launchpad. net,
Message:
Please take a look.
Description:
Bundle deployment views.
This branch includes the implementation of the
bundle views. Their goal is to handle the bundle
deployment request/response process.
Views interact with the Deployer instance
(not yet implemented) in order to schedule,
run and observe bundle deployments.
https:/ /code.launchpad .net/~frankban/ charms/ precise/ juju-gui/ guiserver- views/+ merge/181020
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/12927049/
Affected files: guiserver/ bundles/ __init_ _.py guiserver/ bundles/ utils.py guiserver/ bundles/ views.py guiserver/ tests/bundles/ test_utils. py guiserver/ tests/bundles/ test_views. py guiserver/ tests/helpers. py
A [revision details]
M revision
M server/
A server/
A server/
A server/
A server/
M server/