Merge lp:~frankban/juju-quickstart/changeset-from-jujubundlelib into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 126
Proposed branch: lp:~frankban/juju-quickstart/changeset-from-jujubundlelib
Merge into: lp:juju-quickstart
Diff against target: 453 lines (+186/-61)
4 files modified
quickstart/models/bundles.py (+17/-25)
quickstart/tests/helpers.py (+42/-3)
quickstart/tests/models/test_bundles.py (+123/-29)
tox.ini (+4/-4)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/changeset-from-jujubundlelib
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+257248@code.launchpad.net

Description of the change

Juju bundlelib validation in quickstart.

Validate bundles using jujubundlelib.
Also add to the bundle model the ability
to return its change set.

https://codereview.appspot.com/232890043/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+257248_code.launchpad.net,

Message:
Please take a look.

Description:
Juju bundlelib validation in quickstart.

Validate bundles using jujubundlelib.
Also add to the bundle model the ability
to return its change set.

https://code.launchpad.net/~frankban/juju-quickstart/changeset-from-jujubundlelib/+merge/257248

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/232890043/

Affected files (+188, -61 lines):
   A [revision details]
   M quickstart/models/bundles.py
   M quickstart/tests/helpers.py
   M quickstart/tests/models/test_bundles.py
   M tox.ini

132. By Francesco Banconi

done done

Revision history for this message
Francesco Banconi (frankban) wrote :
Revision history for this message
Jeff Pihach (hatch) wrote :

LGTM!

https://codereview.appspot.com/232890043/diff/20001/quickstart/models/bundles.py
File quickstart/models/bundles.py (right):

https://codereview.appspot.com/232890043/diff/20001/quickstart/models/bundles.py#newcode389
quickstart/models/bundles.py:389: if settings.JUJU_GUI_SERVICE_NAME in
data['services'].keys():
I'm wondering if we shouldn't be a little bit more proactive here by
throwing a warning to the user but then continuing on ignoring that they
supplied their own GUI instance. We could possibly even try to apply
their supplied GUI config.

https://codereview.appspot.com/232890043/

Revision history for this message
Francesco Banconi (frankban) wrote :

On 2015/04/23 14:02:41, jeff.pihach wrote:
> LGTM!

https://codereview.appspot.com/232890043/diff/20001/quickstart/models/bundles.py
> File quickstart/models/bundles.py (right):

https://codereview.appspot.com/232890043/diff/20001/quickstart/models/bundles.py#newcode389
> quickstart/models/bundles.py:389: if settings.JUJU_GUI_SERVICE_NAME in
> data['services'].keys():
> I'm wondering if we shouldn't be a little bit more proactive here by
throwing a
> warning to the user but then continuing on ignoring that they supplied
their own
> GUI instance. We could possibly even try to apply their supplied GUI
config.

Thanks for the review!
This is a good suggestion. Something like this can be implemented later,
but it's not trivial in the current approach which makes use of the
deployer in the backend.

https://codereview.appspot.com/232890043/

Revision history for this message
Madison Scott-Clary (makyo) wrote :
Revision history for this message
Francesco Banconi (frankban) wrote :
Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

Juju bundlelib validation in quickstart.

Validate bundles using jujubundlelib.
Also add to the bundle model the ability
to return its change set.

R=jeff.pihach, matthew.scott
CC=
https://codereview.appspot.com/232890043

https://codereview.appspot.com/232890043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'quickstart/models/bundles.py'
2--- quickstart/models/bundles.py 2015-04-21 10:22:46 +0000
3+++ quickstart/models/bundles.py 2015-04-23 12:16:34 +0000
4@@ -46,7 +46,11 @@
5 import os
6 import re
7
8-from jujubundlelib import references
9+from jujubundlelib import (
10+ changeset,
11+ references,
12+ validation,
13+)
14
15 from quickstart import (
16 charmstore,
17@@ -83,6 +87,13 @@
18 def __repr__(self):
19 return b'<Bundle: {}>'.format(bytes(self))
20
21+ def get_changeset(self):
22+ """Return the collections of changes required to deploy this bundle.
23+
24+ The change set is returned as a generator object.
25+ """
26+ return changeset.parse(self.data)
27+
28 def serialize(self):
29 """Serialize the bundle data as a YAML encoded string."""
30 return serializers.yaml_dump(self.data)
31@@ -277,12 +288,6 @@
32 def parse_yaml(content):
33 """Parse and validate the given bundle content as a YAML encoded string.
34
35- Note that the bundle validation performed by Juju Quickstart is weak by
36- design: it just checks that the content looks like a bundle YAML. Contents
37- provided by the charm store are already known as valid. For other sources,
38- a more cogent validation is done down in the stack, when the content is
39- sent to the GUI server and then to the Juju deployer.
40-
41 Return the resulting YAML decoded dictionary.
42 Raise a ValueError if:
43 - the bundle YAML contents are not parsable by YAML;
44@@ -372,29 +377,16 @@
45 def validate(data):
46 """Validate the given YAML decoded bundle data.
47
48- Note that the bundle validation performed by Juju Quickstart is weak by
49- design: it just checks that the content looks like a bundle YAML. Contents
50- provided by the charm store are already known as valid. For other sources,
51- a more cogent validation is done down in the stack, when the content is
52- sent to the GUI server and then to the Juju deployer.
53-
54 Raise a ValueError if:
55 - the YAML contents are not properly structured;
56 - the bundle does not include services.
57 """
58- _ensure_is_dict(data)
59- # Retrieve the bundle services.
60- try:
61- services = data['services'].keys()
62- except (AttributeError, KeyError, TypeError):
63- content = serializers.yaml_dump(data).strip()
64- msg = 'unable to retrieve bundle services: {}'.format(content)
65+ errors = validation.validate(data)
66+ if errors:
67+ msg = 'invalid bundle data:\n {}'.format('\n '.join(errors))
68 raise ValueError(msg.encode('utf-8'))
69- # Ensure at least one service is defined in the bundle.
70- if not services:
71- raise ValueError(b'no services found in the bundle')
72- # Check that the Juju GUI charm is not included as a service.
73- if settings.JUJU_GUI_SERVICE_NAME in services:
74+ # Check that the Juju GUI charm is not included in the bundle as a service.
75+ if settings.JUJU_GUI_SERVICE_NAME in data['services'].keys():
76 raise ValueError(
77 b'the provided bundle contains an instance of juju-gui. Juju '
78 b'Quickstart will install the latest version of the Juju GUI '
79
80=== modified file 'quickstart/tests/helpers.py'
81--- quickstart/tests/helpers.py 2015-03-09 18:20:47 +0000
82+++ quickstart/tests/helpers.py 2015-04-23 12:16:34 +0000
83@@ -18,7 +18,9 @@
84
85 from __future__ import unicode_literals
86
87+import collections
88 from contextlib import contextmanager
89+import copy
90 import os
91 import shutil
92 import socket
93@@ -47,11 +49,33 @@
94 class BundleFileTestsMixin(object):
95 """Shared methods for testing Juju bundle files."""
96
97- bundle_data = {'services': {'wordpress': {}, 'mysql': {}}}
98+ bundle_data = {
99+ 'services': {
100+ 'wordpress': {
101+ 'charm': 'cs:trusty/wordpress-42',
102+ 'num_units': 1,
103+ },
104+ 'mysql': {
105+ 'charm': 'cs:vivid/mysql-47',
106+ 'num_units': 0,
107+ },
108+ },
109+ }
110 bundle_content = yaml.safe_dump(bundle_data)
111 legacy_bundle_data = {
112- 'bundle1': {'services': {'wordpress': {}, 'mysql': {}}},
113- 'bundle2': {'services': {'django': {}, 'nodejs': {}}},
114+ 'bundle1': bundle_data,
115+ 'bundle2': {
116+ 'services': {
117+ 'django': {
118+ 'charm': 'cs:vivid/django-42',
119+ 'num_units': 2,
120+ },
121+ 'nodejs': {
122+ 'charm': 'nodejs',
123+ 'num_units': 1,
124+ },
125+ },
126+ },
127 }
128 legacy_bundle_content = yaml.safe_dump(legacy_bundle_data)
129
130@@ -269,6 +293,21 @@
131 return {'environments': environments}
132
133
134+def make_ordered_bundle(bundle):
135+ """Given a bundle, return a new one with the same data sorted.
136+
137+ The provided bundle must be an an instance of
138+ "quickstart.models.bundles.Bundle".
139+ The resulting bundle can be used to test its corresponding change set in a
140+ deterministic way.
141+ """
142+ data = copy.deepcopy(bundle.data)
143+ for key, value in data.items():
144+ if isinstance(value, collections.Mapping):
145+ data[key] = collections.OrderedDict(sorted(value.items()))
146+ return bundle.__class__(data, reference=bundle.reference)
147+
148+
149 # Mock the builtin print function.
150 mock_print = mock.patch('__builtin__.print')
151
152
153=== modified file 'quickstart/tests/models/test_bundles.py'
154--- quickstart/tests/models/test_bundles.py 2015-04-21 10:22:46 +0000
155+++ quickstart/tests/models/test_bundles.py 2015-04-23 12:16:34 +0000
156@@ -61,17 +61,58 @@
157 bundle = bundles.Bundle(self.bundle_data)
158 self.assertEqual('<Bundle: bundle>', repr(bundle))
159
160+ def test_get_changeset(self):
161+ # The collection of changes corresponding to the bundle is correctly
162+ # returned.
163+ expected_changeset = (
164+ {'id': 'addCharm-0',
165+ 'method': 'addCharm',
166+ 'args': ['cs:vivid/mysql-47'],
167+ 'requires': []},
168+ {'id': 'addService-1',
169+ 'method': 'deploy',
170+ 'args': ['cs:vivid/mysql-47', 'mysql', {}],
171+ 'requires': ['addCharm-0']},
172+ {'id': 'addCharm-2',
173+ 'method': 'addCharm',
174+ 'args': ['cs:trusty/wordpress-42'],
175+ 'requires': []},
176+ {'id': 'addService-3',
177+ 'method': 'deploy',
178+ 'args': ['cs:trusty/wordpress-42', 'wordpress', {}],
179+ 'requires': ['addCharm-2']},
180+ {'id': 'addUnit-4',
181+ 'method': 'addUnit',
182+ 'args': ['$addService-3', 1, None],
183+ 'requires': ['addService-3']},
184+ )
185+ bundle = helpers.make_ordered_bundle(self.bundle)
186+ self.assertEqual(expected_changeset, tuple(bundle.get_changeset()))
187+
188 def test_serialization(self):
189 # The bundle data is correctly serialized into a YAML encoded string.
190- content = self.bundle.serialize()
191- self.assertEqual('services:\n mysql: {}\n wordpress: {}\n', content)
192+ expected_content = (
193+ 'services:\n'
194+ ' mysql:\n'
195+ ' charm: cs:vivid/mysql-47\n'
196+ ' num_units: 0\n'
197+ ' wordpress:\n'
198+ ' charm: cs:trusty/wordpress-42\n'
199+ ' num_units: 1\n')
200+ self.assertEqual(expected_content, self.bundle.serialize())
201
202 def test_legacy_serialization(self):
203 # The bundle data can be serialized for legacy API version 3.
204- content = self.bundle.serialize_legacy()
205- self.assertEqual(
206- 'bundle:\n services:\n mysql: {}\n wordpress: {}\n',
207- content)
208+ expected_content = (
209+ 'bundle:\n'
210+ ' services:\n'
211+ ' mysql:\n'
212+ ' charm: cs:vivid/mysql-47\n'
213+ ' num_units: 0\n'
214+ ' wordpress:\n'
215+ ' charm: cs:trusty/wordpress-42\n'
216+ ' num_units: 1\n')
217+ self.assertEqual(expected_content, self.bundle.serialize_legacy())
218
219 def test_services(self):
220 # Bundle services can be easily retrieved.
221@@ -167,8 +208,10 @@
222 def test_charmworld_bundle_invalid_content(self):
223 # A ValueError is raised if the content associated with the given
224 # charmworld URL is not valid.
225+ expected_error = (
226+ 'invalid bundle data:\n bundle does not appear to be a bundle')
227 with self.patch_urlread(contents='exterminate!'):
228- with self.assert_value_error('invalid YAML content: exterminate!'):
229+ with self.assert_value_error(expected_error):
230 bundles.from_source('bundle:mediawiki/single')
231
232 def test_charmworld_bundle_connection_error(self):
233@@ -307,8 +350,10 @@
234 def test_jujucharms_bundle_invalid_content(self):
235 # A ValueError is raised if the content associated with the given
236 # jujucharms.com URL are not valid.
237+ expected_error = (
238+ 'invalid bundle data:\n bundle does not appear to be a bundle')
239 with self.patch_urlread(contents='exterminate!'):
240- with self.assert_value_error('invalid YAML content: exterminate!'):
241+ with self.assert_value_error(expected_error):
242 bundles.from_source('wordpress-scalable')
243
244 def test_jujucharms_bundle_connection_error(self):
245@@ -348,7 +393,12 @@
246 # a legacy version 3 bundle.
247 # In this case, the resulting bundle does not have a reference.
248 legacy_bundle_data = {
249- 'bundle': {'services': {'wordpress': {}, 'mysql': {}}},
250+ 'bundle': {
251+ 'services': {
252+ 'wordpress': {'charm': 'wordpress', 'num_units': 1},
253+ 'mysql': {'charm': 'mysql', 'num_units': 1},
254+ }
255+ },
256 }
257 path = self.make_bundle_file(legacy_bundle_data)
258 bundle = bundles.from_source(path)
259@@ -407,7 +457,8 @@
260 # A ValueError is raised if a local file contains an invalid legacy
261 # version 3 content.
262 path = self.make_bundle_file({'bundle': '42'})
263- expected_error = "invalid YAML content: 42"
264+ expected_error = (
265+ 'invalid bundle data:\n bundle does not appear to be a bundle')
266 with self.assert_value_error(expected_error):
267 bundles.from_source(path, 'bundle')
268
269@@ -503,46 +554,62 @@
270
271 def test_yaml_invalid_type(self):
272 # A ValueError is raised if the bundle content is not well formed.
273- with self.assert_value_error('invalid YAML content: a-string'):
274+ expected_error = (
275+ 'invalid bundle data:\n bundle does not appear to be a bundle')
276+ with self.assert_value_error(expected_error):
277 bundles.parse_yaml('a-string')
278
279 def test_yaml_invalid_bundle_data(self):
280 # A ValueError is raised if the bundle content is not well formed.
281 contents = yaml.safe_dump('not valid')
282- with self.assert_value_error('invalid YAML content: not valid'):
283+ expected_error = (
284+ 'invalid bundle data:\n bundle does not appear to be a bundle')
285+ with self.assert_value_error(expected_error):
286 bundles.parse_yaml(contents)
287
288 def test_yaml_no_services(self):
289 # A ValueError is raised if the bundle does not include services.
290 contents = yaml.safe_dump({})
291- with self.assert_value_error('unable to retrieve bundle services: {}'):
292+ expected_error = (
293+ 'invalid bundle data:\n bundle does not define any services')
294+ with self.assert_value_error(expected_error):
295 bundles.parse_yaml(contents)
296
297 def test_yaml_none_bundle_services(self):
298 # A ValueError is raised if services are None.
299 contents = yaml.safe_dump({'services': None})
300- expected = 'unable to retrieve bundle services: services: null'
301- with self.assert_value_error(expected):
302+ expected_error = (
303+ 'invalid bundle data:\n bundle does not define any services')
304+ with self.assert_value_error(expected_error):
305 bundles.parse_yaml(contents)
306
307 def test_yaml_invalid_bundle_services_type(self):
308 # A ValueError is raised if services have an invalid type.
309 contents = yaml.safe_dump({'services': 42})
310- expected = 'unable to retrieve bundle services: services: 42'
311- with self.assert_value_error(expected):
312+ expected_error = (
313+ 'invalid bundle data:\n'
314+ ' services spec does not appear to be well-formed')
315+ with self.assert_value_error(expected_error):
316 bundles.parse_yaml(contents)
317
318 def test_no_services(self):
319 # A ValueError is raised if the specified bundle does not contain
320 # services.
321 contents = yaml.safe_dump({'services': {}})
322- with self.assert_value_error('no services found in the bundle'):
323+ expected_error = (
324+ 'invalid bundle data:\n bundle does not define any services')
325+ with self.assert_value_error(expected_error):
326 bundles.parse_yaml(contents)
327
328 def test_yaml_gui_in_services(self):
329 # A ValueError is raised if the bundle contains juju-gui.
330 contents = yaml.safe_dump({
331- 'services': {settings.JUJU_GUI_SERVICE_NAME: {}},
332+ 'services': {
333+ settings.JUJU_GUI_SERVICE_NAME: {
334+ 'charm': 'juju-gui',
335+ 'num_units': 1,
336+ },
337+ },
338 })
339 expected_error = (
340 'the provided bundle contains an instance of juju-gui. Juju '
341@@ -586,25 +653,32 @@
342
343 def test_yaml_no_services(self):
344 # A ValueError is raised if the bundle does not include services.
345- with self.assert_value_error('unable to retrieve bundle services: {}'):
346+ expected_error = (
347+ 'invalid bundle data:\n bundle does not define any services')
348+ with self.assert_value_error(expected_error):
349 bundles.validate({})
350
351 def test_yaml_none_bundle_services(self):
352 # A ValueError is raised if services are None.
353- expected = 'unable to retrieve bundle services: services: null'
354- with self.assert_value_error(expected):
355+ expected_error = (
356+ 'invalid bundle data:\n bundle does not define any services')
357+ with self.assert_value_error(expected_error):
358 bundles.validate({'services': None})
359
360 def test_yaml_invalid_bundle_services_type(self):
361 # A ValueError is raised if services have an invalid type.
362- expected = 'unable to retrieve bundle services: services: 42'
363- with self.assert_value_error(expected):
364+ expected_error = (
365+ 'invalid bundle data:\n'
366+ ' services spec does not appear to be well-formed')
367+ with self.assert_value_error(expected_error):
368 bundles.validate({'services': 42})
369
370 def test_no_services(self):
371 # A ValueError is raised if the specified bundle does not contain
372 # services.
373- with self.assert_value_error('no services found in the bundle'):
374+ expected_error = (
375+ 'invalid bundle data:\n bundle does not define any services')
376+ with self.assert_value_error(expected_error):
377 bundles.validate({'services': {}})
378
379 def test_yaml_gui_in_services(self):
380@@ -613,10 +687,30 @@
381 'the provided bundle contains an instance of juju-gui. Juju '
382 'Quickstart will install the latest version of the Juju GUI '
383 'automatically; please remove juju-gui from the bundle')
384- with self.assert_value_error(expected_error):
385- bundles.validate({
386- 'services': {settings.JUJU_GUI_SERVICE_NAME: {}},
387- })
388+ data = {
389+ 'services': {
390+ settings.JUJU_GUI_SERVICE_NAME: {
391+ 'charm': 'juju-gui',
392+ 'num_units': 1,
393+ },
394+ },
395+ }
396+ with self.assert_value_error(expected_error):
397+ bundles.validate(data)
398+
399+ def test_multiple_errors(self):
400+ # A ValueError with multiple bundle errors is properly reported.
401+ expected_error = (
402+ 'invalid bundle data:\n'
403+ ' no charm specified for service django\n'
404+ ' num_units for service django must be an integer\n'
405+ ' machine 1 not referred to by a placement directive')
406+ data = {
407+ 'services': {'django': {}},
408+ 'machines': {'1': {}},
409+ }
410+ with self.assert_value_error(expected_error):
411+ bundles.validate(data)
412
413 def test_success(self):
414 # The function succeeds when a valid bundle content is provided.
415
416=== modified file 'tox.ini'
417--- tox.ini 2015-04-21 10:13:12 +0000
418+++ tox.ini 2015-04-23 12:16:34 +0000
419@@ -72,7 +72,7 @@
420 # See https://launchpad.net/~juju/+archive/ubuntu/stable.
421 websocket-client==0.18.0
422 jujuclient==0.50.1
423- jujubundlelib==0.1.1
424+ jujubundlelib==0.1.3
425 urwid==1.2.1
426 # The distribution PyYAML requirement is used in this case.
427
428@@ -82,7 +82,7 @@
429 # Ubuntu 14.04 (trusty) distro dependencies.
430 websocket-client==0.12.0
431 jujuclient==0.17.5
432- jujubundlelib==0.1.1
433+ jujubundlelib==0.1.3
434 PyYAML==3.10
435 urwid==1.1.1
436
437@@ -92,7 +92,7 @@
438 # Ubuntu 14.10 (utopic) distro dependencies.
439 websocket-client==0.12.0
440 jujuclient==0.17.5
441- jujubundlelib==0.1.1
442+ jujubundlelib==0.1.3
443 PyYAML==3.11
444 urwid==1.2.1
445
446@@ -102,7 +102,7 @@
447 # Ubuntu 15.04 (vivid) distro dependencies.
448 websocket-client==0.18.0
449 jujuclient==0.18.5
450- jujubundlelib==0.1.1
451+ jujubundlelib==0.1.3
452 PyYAML==3.11
453 urwid==1.2.1
454

Subscribers

People subscribed via source and target branches