Merge lp:~frankban/juju-quickstart/jujuutils into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 120
Proposed branch: lp:~frankban/juju-quickstart/jujuutils
Merge into: lp:juju-quickstart
Diff against target: 1480 lines (+713/-599)
8 files modified
quickstart/app.py (+6/-4)
quickstart/jujutools.py (+127/-0)
quickstart/manage.py (+3/-2)
quickstart/models/bundles.py (+118/-0)
quickstart/tests/models/test_bundles.py (+254/-0)
quickstart/tests/test_jujutools.py (+204/-0)
quickstart/tests/test_utils.py (+0/-397)
quickstart/utils.py (+1/-196)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/jujuutils
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+249073@code.launchpad.net

Description of the change

Refactor/split utils code.

Move code from utils to the new jujutools and models/bundles modules.
This branch only include moved code and it's done
in preparation for the Juju API new login work.

https://codereview.appspot.com/204770043/

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

Reviewers: mp+249073_code.launchpad.net,

Message:
Please take a look.

Description:
Refactor/split utils code.

Move code from utils to the new jujutools and models/bundles modules.
This branch only include moved code and it's done
in preparation for the Juju API new login work.

https://code.launchpad.net/~frankban/juju-quickstart/jujuutils/+merge/249073

(do not edit description out of merge proposal)

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

Affected files (+715, -599 lines):
   A [revision details]
   M quickstart/app.py
   A quickstart/jujutools.py
   M quickstart/manage.py
   A quickstart/models/bundles.py
   A quickstart/tests/models/test_bundles.py
   A quickstart/tests/test_jujutools.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM. Will do QA shortly.

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

https://codereview.appspot.com/204770043/diff/1/quickstart/models/bundles.py#newcode59
quickstart/models/bundles.py:59: def parse_bundle(bundle_yaml,
bundle_name=None):
Nice that you've made bundles a real person!

https://codereview.appspot.com/204770043/

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

*** Submitted:

Refactor/split utils code.

Move code from utils to the new jujutools and models/bundles modules.
This branch only include moved code and it's done
in preparation for the Juju API new login work.

R=bac
CC=
https://codereview.appspot.com/204770043

https://codereview.appspot.com/204770043/

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

Thanks for the review and QA Brad!

https://codereview.appspot.com/204770043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'quickstart/app.py'
2--- quickstart/app.py 2015-02-09 10:38:25 +0000
3+++ quickstart/app.py 2015-02-09 13:39:50 +0000
4@@ -30,6 +30,7 @@
5
6 from quickstart import (
7 juju,
8+ jujutools,
9 netutils,
10 platform_support,
11 settings,
12@@ -265,11 +266,11 @@
13 continue
14 # Ensure the state server is up and the agent is started.
15 try:
16- agent_state = utils.get_agent_state(output)
17+ agent_state = jujutools.get_agent_state(output)
18 except ValueError:
19 continue
20 if agent_state == 'started':
21- return utils.get_bootstrap_node_series(output)
22+ return jujutools.get_bootstrap_node_series(output)
23 # If the agent is in an error state, there is nothing we can do, and
24 # it's not useful to keep trying.
25 if agent_state == 'error':
26@@ -408,7 +409,8 @@
27 status = env.get_status()
28 except jujuclient.EnvError as err:
29 raise ProgramExit('bad API response: {}'.format(err.message))
30- service_data, unit_data = utils.get_service_info(status, service_name)
31+ service_data, unit_data = jujutools.get_service_info(
32+ status, service_name)
33 if service_data is None:
34 # The service does not exist in the environment.
35 if charm_url is None:
36@@ -429,7 +431,7 @@
37 # A deployed service already exists in the environment: ignore the
38 # provided charm URL and just use the already deployed charm.
39 charm_url = service_data['CharmURL']
40- charm = utils.parse_gui_charm_url(charm_url)
41+ charm = jujutools.parse_gui_charm_url(charm_url)
42 # Deploy on the bootstrap node if the following conditions are satisfied:
43 # - we are not using the local provider (which uses localhost);
44 # - we are not using the azure provider (in which availability sets prevent
45
46=== added file 'quickstart/jujutools.py'
47--- quickstart/jujutools.py 1970-01-01 00:00:00 +0000
48+++ quickstart/jujutools.py 2015-02-09 13:39:50 +0000
49@@ -0,0 +1,127 @@
50+# This file is part of the Juju Quickstart Plugin, which lets users set up a
51+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
52+# Copyright (C) 2015 Canonical Ltd.
53+#
54+# This program is free software: you can redistribute it and/or modify it under
55+# the terms of the GNU Affero General Public License version 3, as published by
56+# the Free Software Foundation.
57+#
58+# This program is distributed in the hope that it will be useful, but WITHOUT
59+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
60+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
61+# Affero General Public License for more details.
62+#
63+# You should have received a copy of the GNU Affero General Public License
64+# along with this program. If not, see <http://www.gnu.org/licenses/>.
65+
66+"""Quickstart utility functions for managing Juju environments and entities."""
67+
68+from __future__ import (
69+ print_function,
70+ unicode_literals,
71+)
72+
73+import logging
74+
75+from quickstart import (
76+ serializers,
77+ settings,
78+)
79+from quickstart.models import charms
80+
81+
82+def get_service_info(status, service_name):
83+ """Retrieve information on the given service and on its first alive unit.
84+
85+ Return a tuple containing two values: (service data, unit data).
86+ Each value can be:
87+ - a dictionary of data about the given entity (service or unit) as
88+ returned by the Juju watcher;
89+ - None, if the entity is not present in the Juju environment.
90+ If the service data is None, the unit data is always None.
91+ """
92+ services = [
93+ data for entity, action, data in status if
94+ (entity == 'service') and (action != 'remove') and
95+ (data['Name'] == service_name) and (data['Life'] == 'alive')
96+ ]
97+ if not services:
98+ return None, None
99+ units = [
100+ data for entity, action, data in status if
101+ entity == 'unit' and action != 'remove' and
102+ data['Service'] == service_name
103+ ]
104+ return services[0], units[0] if units else None
105+
106+
107+def parse_gui_charm_url(charm_url):
108+ """Parse the given charm URL.
109+
110+ Check if the charm looks like a Juju GUI charm.
111+ Print (to stdout or to logs) info and warnings about the charm URL.
112+
113+ Return the parsed charm object as an instance of
114+ quickstart.models.charms.Charm.
115+ """
116+ print('charm URL: {}'.format(charm_url))
117+ charm = charms.Charm.from_url(charm_url)
118+ charm_name = settings.JUJU_GUI_CHARM_NAME
119+ if charm.name != charm_name:
120+ # This does not seem to be a Juju GUI charm.
121+ logging.warn(
122+ 'unexpected URL for the {} charm: '
123+ 'the service may not work as expected'.format(charm_name))
124+ return charm
125+ if charm.user or charm.is_local():
126+ # This is not the official Juju GUI charm.
127+ logging.warn('using a customized {} charm'.format(charm_name))
128+ elif charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]:
129+ # This is the official Juju GUI charm, but it is outdated.
130+ logging.warn(
131+ 'charm is outdated and may not support bundle deployments')
132+ return charm
133+
134+
135+def parse_status_output(output, keys=None):
136+ """Parse the output of juju status.
137+
138+ Return selection specified by the keys array.
139+ Raise a ValueError if the selection cannot be retrieved.
140+ """
141+ if keys is None:
142+ keys = ['dummy']
143+ try:
144+ status = serializers.yaml_load(output)
145+ except Exception as err:
146+ raise ValueError(b'unable to parse the output: {}'.format(err))
147+
148+ selection = status
149+ for key in keys:
150+ try:
151+ selection = selection.get(key, {})
152+ except AttributeError as err:
153+ msg = 'invalid YAML contents: {}'.format(status)
154+ raise ValueError(msg.encode('utf-8'))
155+ if selection == {}:
156+ msg = '{} not found in {}'.format(':'.join(keys), status)
157+ raise ValueError(msg.encode('utf-8'))
158+ return selection
159+
160+
161+def get_agent_state(output):
162+ """Parse the output of juju status for the agent state.
163+
164+ Return the agent state.
165+ Raise a ValueError if the agent state cannot be retrieved.
166+ """
167+ return parse_status_output(output, ['machines', '0', 'agent-state'])
168+
169+
170+def get_bootstrap_node_series(output):
171+ """Parse the output of juju status for the agent state.
172+
173+ Return the agent state.
174+ Raise a ValueError if the agent state cannot be retrieved.
175+ """
176+ return parse_status_output(output, ['machines', '0', 'series'])
177
178=== modified file 'quickstart/manage.py'
179--- quickstart/manage.py 2015-01-30 15:27:07 +0000
180+++ quickstart/manage.py 2015-02-09 13:39:50 +0000
181@@ -43,6 +43,7 @@
182 views,
183 )
184 from quickstart.models import (
185+ bundles,
186 charms,
187 envs,
188 jenv,
189@@ -107,7 +108,7 @@
190 if bundle.startswith('bundle:') or bundle.startswith(jujucharms_prefix):
191 # Convert "bundle:" or jujucharms.com URLs into Charmworld HTTPS ones.
192 try:
193- bundle, bundle_id = utils.convert_bundle_url(bundle)
194+ bundle, bundle_id = bundles.convert_bundle_url(bundle)
195 except ValueError as err:
196 return parser.error('unable to open the bundle: {}'.format(err))
197 # The next if block below will then load the bundle contents from the
198@@ -130,7 +131,7 @@
199 return parser.error('unable to open bundle file: {}'.format(err))
200 # Validate the bundle.
201 try:
202- bundle_name, bundle_services = utils.parse_bundle(
203+ bundle_name, bundle_services = bundles.parse_bundle(
204 bundle_yaml, options.bundle_name)
205 except ValueError as err:
206 return parser.error(bytes(err))
207
208=== added file 'quickstart/models/bundles.py'
209--- quickstart/models/bundles.py 1970-01-01 00:00:00 +0000
210+++ quickstart/models/bundles.py 2015-02-09 13:39:50 +0000
211@@ -0,0 +1,118 @@
212+# This file is part of the Juju Quickstart Plugin, which lets users set up a
213+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
214+# Copyright (C) 2015 Canonical Ltd.
215+#
216+# This program is free software: you can redistribute it and/or modify it under
217+# the terms of the GNU Affero General Public License version 3, as published by
218+# the Free Software Foundation.
219+#
220+# This program is distributed in the hope that it will be useful, but WITHOUT
221+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
222+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
223+# Affero General Public License for more details.
224+#
225+# You should have received a copy of the GNU Affero General Public License
226+# along with this program. If not, see <http://www.gnu.org/licenses/>.
227+
228+"""Juju Quickstart bundles management."""
229+
230+from __future__ import unicode_literals
231+
232+import collections
233+import re
234+
235+from quickstart import (
236+ serializers,
237+ settings,
238+)
239+
240+
241+# Compile the regular expression used to parse bundle URLs.
242+_bundle_expression = re.compile(r"""
243+ # Bundle schema or bundle URL namespace on jujucharms.com.
244+ ^(?:bundle:|{})
245+ (?:~([-\w]+)/)? # Optional user name.
246+ ([-\w]+)/ # Basket name.
247+ (?:(\d+)/)? # Optional bundle revision number.
248+ ([-\w]+) # Bundle name.
249+ /?$ # Optional trailing slash.
250+""".format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE)
251+
252+
253+def convert_bundle_url(bundle_url):
254+ """Return the equivalent YAML HTTPS location for the given bundle URL.
255+
256+ Raise a ValueError if the given URL is not a valid bundle URL.
257+ """
258+ match = _bundle_expression.match(bundle_url)
259+ if match is None:
260+ msg = 'invalid bundle URL: {}'.format(bundle_url)
261+ raise ValueError(msg.encode('utf-8'))
262+ user, basket, revision, name = match.groups()
263+ user_part = '~charmers/' if user is None else '~{}/'.format(user)
264+ revision_part = '' if revision is None else '{}/'.format(revision)
265+ bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name)
266+ return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id),
267+ bundle_id)
268+
269+
270+def parse_bundle(bundle_yaml, bundle_name=None):
271+ """Parse the provided bundle YAML encoded contents.
272+
273+ Since a valid JSON is a subset of YAML this function can be used also to
274+ parse JSON encoded contents.
275+
276+ Return a tuple containing the bundle name and the list of services included
277+ in the bundle.
278+
279+ Raise a ValueError if:
280+ - the bundle YAML contents are not parsable by YAML;
281+ - the YAML contents are not properly structured;
282+ - the bundle name is specified but not included in the bundle file;
283+ - the bundle name is not specified and the bundle file includes more than
284+ one bundle;
285+ - the bundle does not include services.
286+ """
287+ # Parse the bundle file.
288+ try:
289+ bundles = serializers.yaml_load(bundle_yaml)
290+ except Exception as err:
291+ msg = b'unable to parse the bundle: {}'.format(err)
292+ raise ValueError(msg)
293+ # Ensure the bundle file is well formed and contains at least one bundle.
294+ if not isinstance(bundles, collections.Mapping):
295+ msg = 'invalid YAML contents: {}'.format(bundle_yaml)
296+ raise ValueError(msg.encode('utf-8'))
297+ try:
298+ name_services_map = dict(
299+ (key, value['services'].keys())
300+ for key, value in bundles.items()
301+ )
302+ except (AttributeError, KeyError, TypeError):
303+ msg = 'invalid YAML contents: {}'.format(bundle_yaml)
304+ raise ValueError(msg.encode('utf-8'))
305+ if not name_services_map:
306+ raise ValueError(b'no bundles found')
307+ # Retrieve the bundle name and services.
308+ if bundle_name is None:
309+ if len(name_services_map) > 1:
310+ msg = 'multiple bundles found ({}) but no bundle name specified'
311+ bundle_names = ', '.join(sorted(name_services_map.keys()))
312+ raise ValueError(msg.format(bundle_names).encode('utf-8'))
313+ bundle_name, bundle_services = name_services_map.items()[0]
314+ else:
315+ bundle_services = name_services_map.get(bundle_name)
316+ if bundle_services is None:
317+ msg = 'bundle {} not found in the provided list of bundles ({})'
318+ bundle_names = ', '.join(sorted(name_services_map.keys()))
319+ raise ValueError(
320+ msg.format(bundle_name, bundle_names).encode('utf-8'))
321+ if not bundle_services:
322+ msg = 'bundle {} does not include any services'.format(bundle_name)
323+ raise ValueError(msg.encode('utf-8'))
324+ if settings.JUJU_GUI_SERVICE_NAME in bundle_services:
325+ msg = ('bundle {} contains an instance of juju-gui. quickstart will '
326+ 'install the latest version of the Juju GUI automatically, '
327+ 'please remove juju-gui from the bundle.'.format(bundle_name))
328+ raise ValueError(msg.encode('utf-8'))
329+ return bundle_name, bundle_services
330
331=== added file 'quickstart/tests/models/test_bundles.py'
332--- quickstart/tests/models/test_bundles.py 1970-01-01 00:00:00 +0000
333+++ quickstart/tests/models/test_bundles.py 2015-02-09 13:39:50 +0000
334@@ -0,0 +1,254 @@
335+# This file is part of the Juju Quickstart Plugin, which lets users set up a
336+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
337+# Copyright (C) 2015 Canonical Ltd.
338+#
339+# This program is free software: you can redistribute it and/or modify it under
340+# the terms of the GNU Affero General Public License version 3, as published by
341+# the Free Software Foundation.
342+#
343+# This program is distributed in the hope that it will be useful, but WITHOUT
344+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
345+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
346+# Affero General Public License for more details.
347+#
348+# You should have received a copy of the GNU Affero General Public License
349+# along with this program. If not, see <http://www.gnu.org/licenses/>.
350+
351+"""Tests for the Juju Quickstart bundles management."""
352+
353+from __future__ import unicode_literals
354+
355+import json
356+import unittest
357+
358+import yaml
359+
360+from quickstart import settings
361+from quickstart.models import bundles
362+from quickstart.tests import helpers
363+
364+
365+class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):
366+
367+ def test_full_bundle_url(self):
368+ # The HTTPS location to the YAML contents is correctly returned.
369+ bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki'
370+ url, bundle_id = bundles.convert_bundle_url(bundle_url)
371+ self.assertEqual(
372+ 'https://manage.jujucharms.com'
373+ '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
374+ self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
375+
376+ def test_bundle_url_right_strip(self):
377+ # The trailing slash in the bundle URL is removed.
378+ bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/'
379+ url, bundle_id = bundles.convert_bundle_url(bundle_url)
380+ self.assertEqual(
381+ 'https://manage.jujucharms.com'
382+ '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
383+ self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
384+
385+ def test_bundle_url_no_revision(self):
386+ # The bundle revision is optional.
387+ bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple'
388+ url, bundle_id = bundles.convert_bundle_url(bundle_url)
389+ self.assertEqual(
390+ 'https://manage.jujucharms.com'
391+ '/bundle/~myuser/wiki-bundle/wiki-simple/json', url)
392+ self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id)
393+
394+ def test_bundle_url_no_user(self):
395+ # If the bundle user is not specified, the bundle is assumed to be
396+ # promulgated and owned by "charmers".
397+ bundle_url = 'bundle:wiki-bundle/1/wiki'
398+ url, bundle_id = bundles.convert_bundle_url(bundle_url)
399+ self.assertEqual(
400+ 'https://manage.jujucharms.com'
401+ '/bundle/~charmers/wiki-bundle/1/wiki/json', url)
402+ self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id)
403+
404+ def test_bundle_url_short_form(self):
405+ # A promulgated bundle URL can just include the basket and the name.
406+ bundle_url = 'bundle:wiki-bundle/wiki'
407+ url, bundle_id = bundles.convert_bundle_url(bundle_url)
408+ self.assertEqual(
409+ 'https://manage.jujucharms.com'
410+ '/bundle/~charmers/wiki-bundle/wiki/json', url)
411+ self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
412+
413+ def test_full_jujucharms_url(self):
414+ # The HTTPS location to the YAML contents is correctly returned.
415+ url, bundle_id = bundles.convert_bundle_url(
416+ settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki')
417+ self.assertEqual(
418+ 'https://manage.jujucharms.com'
419+ '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
420+ self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
421+
422+ def test_jujucharms_url_right_strip(self):
423+ # The trailing slash in the jujucharms URL is removed.
424+ url, bundle_id = bundles.convert_bundle_url(
425+ settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/')
426+ self.assertEqual(
427+ 'https://manage.jujucharms.com'
428+ '/bundle/~charmers/mediawiki/6/scalable/json', url)
429+ self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id)
430+
431+ def test_jujucharms_url_no_revision(self):
432+ # The bundle revision is optional.
433+ url, bundle_id = bundles.convert_bundle_url(
434+ settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/')
435+ self.assertEqual(
436+ 'https://manage.jujucharms.com'
437+ '/bundle/~myuser/wiki/wiki-simple/json', url)
438+ self.assertEqual('~myuser/wiki/wiki-simple', bundle_id)
439+
440+ def test_jujucharms_url_no_user(self):
441+ # If the bundle user is not specified, the bundle is assumed to be
442+ # promulgated and owned by "charmers".
443+ url, bundle_id = bundles.convert_bundle_url(
444+ settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/')
445+ self.assertEqual(
446+ 'https://manage.jujucharms.com'
447+ '/bundle/~charmers/mediawiki/42/single/json', url)
448+ self.assertEqual('~charmers/mediawiki/42/single', bundle_id)
449+
450+ def test_jujucharms_url_short_form(self):
451+ # A jujucharms URL for a promulgated bundle can just include the basket
452+ # and the name.
453+ url, bundle_id = bundles.convert_bundle_url(
454+ settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/')
455+ self.assertEqual(
456+ 'https://manage.jujucharms.com'
457+ '/bundle/~charmers/wiki-bundle/wiki/json', url)
458+ self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
459+
460+ def test_error(self):
461+ # A ValueError is raised if the bundle/jujucharms URL is not valid.
462+ bad_urls = (
463+ 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such',
464+ 'bundle:~user/name', 'bundle:~user/basket/revision/name',
465+ 'bundle:basket/name//', 'bundle:basket.name/bundle.name',
466+ settings.JUJUCHARMS_BUNDLE_URL,
467+ settings.JUJUCHARMS_BUNDLE_URL + 'bad',
468+ settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such',
469+ settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/',
470+ settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error',
471+ 'https://jujucharms.com/charms/mediawiki/simple/',
472+ )
473+ for url in bad_urls:
474+ with self.assert_value_error('invalid bundle URL: {}'.format(url)):
475+ bundles.convert_bundle_url(url)
476+
477+
478+class TestParseBundle(
479+ helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
480+ unittest.TestCase):
481+
482+ def assert_bundle(
483+ self, expected_name, expected_services, contents,
484+ bundle_name=None):
485+ """Ensure parsing the given contents returns the expected values."""
486+ name, services = bundles.parse_bundle(
487+ contents, bundle_name=bundle_name)
488+ self.assertEqual(expected_name, name)
489+ self.assertEqual(set(expected_services), set(services))
490+
491+ def test_invalid_yaml(self):
492+ # A ValueError is raised if the bundle contents are not a valid YAML.
493+ with self.assertRaises(ValueError) as context_manager:
494+ bundles.parse_bundle(':')
495+ expected = 'unable to parse the bundle'
496+ self.assertIn(expected, bytes(context_manager.exception))
497+
498+ def test_yaml_invalid_type(self):
499+ # A ValueError is raised if the bundle contents are not well formed.
500+ with self.assert_value_error('invalid YAML contents: a-string'):
501+ bundles.parse_bundle('a-string')
502+
503+ def test_yaml_invalid_bundle_data(self):
504+ # A ValueError is raised if bundles are not well formed.
505+ contents = yaml.safe_dump({'mybundle': 'not valid'})
506+ expected = 'invalid YAML contents: {mybundle: not valid}\n'
507+ with self.assert_value_error(expected):
508+ bundles.parse_bundle(contents)
509+
510+ def test_yaml_no_service(self):
511+ # A ValueError is raised if bundles do not include services.
512+ contents = yaml.safe_dump({'mybundle': {}})
513+ expected = 'invalid YAML contents: mybundle: {}\n'
514+ with self.assert_value_error(expected):
515+ bundles.parse_bundle(contents)
516+
517+ def test_yaml_none_bundle_services(self):
518+ # A ValueError is raised if services are None.
519+ contents = yaml.safe_dump({'mybundle': {'services': None}})
520+ expected = 'invalid YAML contents: mybundle: {services: null}\n'
521+ with self.assert_value_error(expected):
522+ bundles.parse_bundle(contents)
523+
524+ def test_yaml_invalid_bundle_services_type(self):
525+ # A ValueError is raised if services have an invalid type.
526+ contents = yaml.safe_dump({'mybundle': {'services': 42}})
527+ expected = 'invalid YAML contents: mybundle: {services: 42}\n'
528+ with self.assert_value_error(expected):
529+ bundles.parse_bundle(contents)
530+
531+ def test_yaml_no_bundles(self):
532+ # A ValueError is raised if the bundle contents are empty.
533+ with self.assert_value_error('no bundles found'):
534+ bundles.parse_bundle(yaml.safe_dump({}))
535+
536+ def test_bundle_name_not_specified(self):
537+ # A ValueError is raised if the bundle name is not specified and the
538+ # contents contain more than one bundle.
539+ expected = ('multiple bundles found (bundle1, bundle2) '
540+ 'but no bundle name specified')
541+ with self.assert_value_error(expected):
542+ bundles.parse_bundle(self.valid_bundle)
543+
544+ def test_bundle_name_not_found(self):
545+ # A ValueError is raised if the given bundle is not found in the file.
546+ expected = ('bundle no-such not found in the provided list of bundles '
547+ '(bundle1, bundle2)')
548+ with self.assert_value_error(expected):
549+ bundles.parse_bundle(self.valid_bundle, 'no-such')
550+
551+ def test_no_services(self):
552+ # A ValueError is raised if the specified bundle does not contain
553+ # services.
554+ contents = yaml.safe_dump({'mybundle': {'services': {}}})
555+ expected = 'bundle mybundle does not include any services'
556+ with self.assert_value_error(expected):
557+ bundles.parse_bundle(contents)
558+
559+ def test_yaml_gui_in_services(self):
560+ # A ValueError is raised if the bundle contains juju-gui.
561+ contents = yaml.safe_dump({
562+ 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}},
563+ })
564+ expected = 'bundle mybundle contains an instance of juju-gui. ' \
565+ 'quickstart will install the latest version of the Juju GUI ' \
566+ 'automatically, please remove juju-gui from the bundle.'
567+ with self.assert_value_error(expected):
568+ bundles.parse_bundle(contents)
569+
570+ def test_success_no_name(self):
571+ # The function succeeds when an implicit bundle name is used.
572+ contents = yaml.safe_dump({
573+ 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
574+ })
575+ self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
576+
577+ def test_success_multiple_bundles(self):
578+ # The function succeeds with multiple bundles.
579+ self.assert_bundle(
580+ 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2')
581+
582+ def test_success_json(self):
583+ # Since JSON is a subset of YAML, the function also support JSON
584+ # encoded bundles.
585+ contents = json.dumps({
586+ 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
587+ })
588+ self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
589
590=== added file 'quickstart/tests/test_jujutools.py'
591--- quickstart/tests/test_jujutools.py 1970-01-01 00:00:00 +0000
592+++ quickstart/tests/test_jujutools.py 2015-02-09 13:39:50 +0000
593@@ -0,0 +1,204 @@
594+# This file is part of the Juju Quickstart Plugin, which lets users set up a
595+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
596+# Copyright (C) 2015 Canonical Ltd.
597+#
598+# This program is free software: you can redistribute it and/or modify it under
599+# the terms of the GNU Affero General Public License version 3, as published by
600+# the Free Software Foundation.
601+#
602+# This program is distributed in the hope that it will be useful, but WITHOUT
603+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
604+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
605+# Affero General Public License for more details.
606+#
607+# You should have received a copy of the GNU Affero General Public License
608+# along with this program. If not, see <http://www.gnu.org/licenses/>.
609+
610+"""Tests for the Quickstart Juju utilities and helpers."""
611+
612+from __future__ import unicode_literals
613+
614+import unittest
615+
616+import mock
617+import yaml
618+
619+from quickstart import jujutools
620+from quickstart.models import charms
621+from quickstart.tests import helpers
622+
623+
624+class TestGetServiceInfo(helpers.WatcherDataTestsMixin, unittest.TestCase):
625+
626+ def test_service_and_unit(self):
627+ # The data about the given service and unit is correctly returned.
628+ service_change = self.make_service_change()
629+ unit_change = self.make_unit_change()
630+ status = [service_change, unit_change]
631+ expected = (service_change[2], unit_change[2])
632+ self.assertEqual(
633+ expected, jujutools.get_service_info(status, 'my-gui'))
634+
635+ def test_service_only(self):
636+ # The data about the given service without units is correctly returned.
637+ service_change = self.make_service_change()
638+ status = [service_change]
639+ expected = (service_change[2], None)
640+ self.assertEqual(
641+ expected, jujutools.get_service_info(status, 'my-gui'))
642+
643+ def test_service_removed(self):
644+ # A tuple (None, None) is returned if the service is being removed.
645+ status = [
646+ self.make_service_change(action='remove'),
647+ self.make_unit_change(),
648+ ]
649+ expected = (None, None)
650+ self.assertEqual(
651+ expected, jujutools.get_service_info(status, 'my-gui'))
652+
653+ def test_another_service(self):
654+ # A tuple (None, None) is returned if the service is not found.
655+ status = [
656+ self.make_service_change(data={'Name': 'another-service'}),
657+ self.make_unit_change(),
658+ ]
659+ expected = (None, None)
660+ self.assertEqual(
661+ expected, jujutools.get_service_info(status, 'my-gui'))
662+
663+ def test_service_not_alive(self):
664+ # A tuple (None, None) is returned if the service is not alive.
665+ status = [
666+ self.make_service_change(data={'Life': 'dying'}),
667+ self.make_unit_change(),
668+ ]
669+ expected = (None, None)
670+ self.assertEqual(
671+ expected, jujutools.get_service_info(status, 'my-gui'))
672+
673+ def test_unit_removed(self):
674+ # The unit data is not returned if the unit is being removed.
675+ service_change = self.make_service_change()
676+ status = [service_change, self.make_unit_change(action='remove')]
677+ expected = (service_change[2], None)
678+ self.assertEqual(
679+ expected, jujutools.get_service_info(status, 'my-gui'))
680+
681+ def test_another_unit(self):
682+ # The unit data is not returned if the unit belongs to another service.
683+ service_change = self.make_service_change()
684+ status = [
685+ service_change,
686+ self.make_unit_change(data={'Service': 'another-service'}),
687+ ]
688+ expected = (service_change[2], None)
689+ self.assertEqual(
690+ expected, jujutools.get_service_info(status, 'my-gui'))
691+
692+ def test_no_services(self):
693+ # A tuple (None, None) is returned no services are found.
694+ status = [self.make_unit_change()]
695+ expected = (None, None)
696+ self.assertEqual(
697+ expected, jujutools.get_service_info(status, 'my-gui'))
698+
699+ def test_no_entities(self):
700+ # A tuple (None, None) is returned no entities are found.
701+ expected = (None, None)
702+ self.assertEqual(expected, jujutools.get_service_info([], 'my-gui'))
703+
704+
705+@mock.patch('__builtin__.print', mock.Mock())
706+class TestParseGuiCharmUrl(unittest.TestCase):
707+
708+ def test_charm_instance_returned(self):
709+ # A charm instance is correctly returned.
710+ charm = jujutools.parse_gui_charm_url('cs:trusty/juju-gui-42')
711+ self.assertIsInstance(charm, charms.Charm)
712+ self.assertEqual('cs:trusty/juju-gui-42', charm.url())
713+
714+ def test_customized(self):
715+ # A customized charm URL is properly logged.
716+ expected = 'using a customized juju-gui charm'
717+ with helpers.assert_logs([expected], level='warn'):
718+ jujutools.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')
719+
720+ def test_outdated(self):
721+ # An outdated charm URL is properly logged.
722+ expected = 'charm is outdated and may not support bundle deployments'
723+ with helpers.assert_logs([expected], level='warn'):
724+ jujutools.parse_gui_charm_url('cs:precise/juju-gui-1')
725+
726+ def test_unexpected(self):
727+ # An unexpected charm URL is properly logged.
728+ expected = (
729+ 'unexpected URL for the juju-gui charm: the service may not work '
730+ 'as expected')
731+ with helpers.assert_logs([expected], level='warn'):
732+ jujutools.parse_gui_charm_url('cs:precise/another-gui-42')
733+
734+ def test_official(self):
735+ # No warnings are logged if an up to date charm is passed.
736+ with mock.patch('logging.warn') as mock_warn:
737+ jujutools.parse_gui_charm_url('cs:precise/juju-gui-100')
738+ self.assertFalse(mock_warn.called)
739+
740+
741+class TestParseStatusOutput(helpers.ValueErrorTestsMixin, unittest.TestCase):
742+
743+ def test_invalid_yaml(self):
744+ # A ValueError is raised if the output is not a valid YAML.
745+ with self.assertRaises(ValueError) as context_manager:
746+ jujutools.parse_status_output(':')
747+ expected = 'unable to parse the output'
748+ self.assertIn(expected, bytes(context_manager.exception))
749+
750+ def test_invalid_yaml_contents(self):
751+ # A ValueError is raised if the output is not well formed.
752+ with self.assert_value_error('invalid YAML contents: a-string'):
753+ jujutools.parse_status_output('a-string')
754+
755+ def test_no_agent_state(self):
756+ # A ValueError is raised if the agent-state is not found in the YAML.
757+ data = {
758+ 'machines': {
759+ '0': {'agent-version': '1.17.0.1'},
760+ },
761+ }
762+ expected = 'machines:0:agent-state not found in {}'.format(bytes(data))
763+ with self.assert_value_error(expected):
764+ jujutools.get_agent_state(yaml.safe_dump(data))
765+
766+ def test_success_agent_state(self):
767+ # The agent state is correctly returned.
768+ output = yaml.safe_dump({
769+ 'machines': {
770+ '0': {'agent-version': '1.17.0.1', 'agent-state': 'started'},
771+ },
772+ })
773+ agent_state = jujutools.get_agent_state(output)
774+ self.assertEqual('started', agent_state)
775+
776+ def test_no_bootstrap_node_series(self):
777+ # A ValueError is raised if the series is not found in the YAML.
778+ data = {
779+ 'machines': {
780+ '0': {'agent-version': '1.17.0.1'},
781+ },
782+ }
783+ expected = 'machines:0:series not found in {}'.format(bytes(data))
784+ with self.assert_value_error(expected):
785+ jujutools.get_bootstrap_node_series(yaml.safe_dump(data))
786+
787+ def test_success_bootstrap_node_series(self):
788+ # The bootstrap node series is correctly returned.
789+ output = yaml.safe_dump({
790+ 'machines': {
791+ '0': {'agent-version': '1.17.0.1',
792+ 'agent-state': 'started',
793+ 'series': 'zydeco'},
794+ },
795+ })
796+ bsn_series = jujutools.get_bootstrap_node_series(output)
797+ self.assertEqual('zydeco', bsn_series)
798
799=== modified file 'quickstart/tests/test_utils.py'
800--- quickstart/tests/test_utils.py 2014-11-11 19:08:57 +0000
801+++ quickstart/tests/test_utils.py 2015-02-09 13:39:50 +0000
802@@ -19,21 +19,17 @@
803 from __future__ import unicode_literals
804
805 import datetime
806-import json
807 import os
808 import shutil
809 import tempfile
810 import unittest
811
812 import mock
813-import yaml
814
815 from quickstart import (
816 get_version,
817- settings,
818 utils,
819 )
820-from quickstart.models import charms
821 from quickstart.tests import helpers
822
823
824@@ -153,155 +149,6 @@
825 utils.call('echo', 'we are the borg!')
826
827
828-@mock.patch('__builtin__.print', mock.Mock())
829-class TestParseGuiCharmUrl(unittest.TestCase):
830-
831- def test_charm_instance_returned(self):
832- # A charm instance is correctly returned.
833- charm = utils.parse_gui_charm_url('cs:trusty/juju-gui-42')
834- self.assertIsInstance(charm, charms.Charm)
835- self.assertEqual('cs:trusty/juju-gui-42', charm.url())
836-
837- def test_customized(self):
838- # A customized charm URL is properly logged.
839- expected = 'using a customized juju-gui charm'
840- with helpers.assert_logs([expected], level='warn'):
841- utils.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')
842-
843- def test_outdated(self):
844- # An outdated charm URL is properly logged.
845- expected = 'charm is outdated and may not support bundle deployments'
846- with helpers.assert_logs([expected], level='warn'):
847- utils.parse_gui_charm_url('cs:precise/juju-gui-1')
848-
849- def test_unexpected(self):
850- # An unexpected charm URL is properly logged.
851- expected = (
852- 'unexpected URL for the juju-gui charm: the service may not work '
853- 'as expected')
854- with helpers.assert_logs([expected], level='warn'):
855- utils.parse_gui_charm_url('cs:precise/another-gui-42')
856-
857- def test_official(self):
858- # No warnings are logged if an up to date charm is passed.
859- with mock.patch('logging.warn') as mock_warn:
860- utils.parse_gui_charm_url('cs:precise/juju-gui-100')
861- self.assertFalse(mock_warn.called)
862-
863-
864-class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):
865-
866- def test_full_bundle_url(self):
867- # The HTTPS location to the YAML contents is correctly returned.
868- bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki'
869- url, bundle_id = utils.convert_bundle_url(bundle_url)
870- self.assertEqual(
871- 'https://manage.jujucharms.com'
872- '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
873- self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
874-
875- def test_bundle_url_right_strip(self):
876- # The trailing slash in the bundle URL is removed.
877- bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/'
878- url, bundle_id = utils.convert_bundle_url(bundle_url)
879- self.assertEqual(
880- 'https://manage.jujucharms.com'
881- '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
882- self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
883-
884- def test_bundle_url_no_revision(self):
885- # The bundle revision is optional.
886- bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple'
887- url, bundle_id = utils.convert_bundle_url(bundle_url)
888- self.assertEqual(
889- 'https://manage.jujucharms.com'
890- '/bundle/~myuser/wiki-bundle/wiki-simple/json', url)
891- self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id)
892-
893- def test_bundle_url_no_user(self):
894- # If the bundle user is not specified, the bundle is assumed to be
895- # promulgated and owned by "charmers".
896- bundle_url = 'bundle:wiki-bundle/1/wiki'
897- url, bundle_id = utils.convert_bundle_url(bundle_url)
898- self.assertEqual(
899- 'https://manage.jujucharms.com'
900- '/bundle/~charmers/wiki-bundle/1/wiki/json', url)
901- self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id)
902-
903- def test_bundle_url_short_form(self):
904- # A promulgated bundle URL can just include the basket and the name.
905- bundle_url = 'bundle:wiki-bundle/wiki'
906- url, bundle_id = utils.convert_bundle_url(bundle_url)
907- self.assertEqual(
908- 'https://manage.jujucharms.com'
909- '/bundle/~charmers/wiki-bundle/wiki/json', url)
910- self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
911-
912- def test_full_jujucharms_url(self):
913- # The HTTPS location to the YAML contents is correctly returned.
914- url, bundle_id = utils.convert_bundle_url(
915- settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki')
916- self.assertEqual(
917- 'https://manage.jujucharms.com'
918- '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
919- self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
920-
921- def test_jujucharms_url_right_strip(self):
922- # The trailing slash in the jujucharms URL is removed.
923- url, bundle_id = utils.convert_bundle_url(
924- settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/')
925- self.assertEqual(
926- 'https://manage.jujucharms.com'
927- '/bundle/~charmers/mediawiki/6/scalable/json', url)
928- self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id)
929-
930- def test_jujucharms_url_no_revision(self):
931- # The bundle revision is optional.
932- url, bundle_id = utils.convert_bundle_url(
933- settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/')
934- self.assertEqual(
935- 'https://manage.jujucharms.com'
936- '/bundle/~myuser/wiki/wiki-simple/json', url)
937- self.assertEqual('~myuser/wiki/wiki-simple', bundle_id)
938-
939- def test_jujucharms_url_no_user(self):
940- # If the bundle user is not specified, the bundle is assumed to be
941- # promulgated and owned by "charmers".
942- url, bundle_id = utils.convert_bundle_url(
943- settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/')
944- self.assertEqual(
945- 'https://manage.jujucharms.com'
946- '/bundle/~charmers/mediawiki/42/single/json', url)
947- self.assertEqual('~charmers/mediawiki/42/single', bundle_id)
948-
949- def test_jujucharms_url_short_form(self):
950- # A jujucharms URL for a promulgated bundle can just include the basket
951- # and the name.
952- url, bundle_id = utils.convert_bundle_url(
953- settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/')
954- self.assertEqual(
955- 'https://manage.jujucharms.com'
956- '/bundle/~charmers/wiki-bundle/wiki/json', url)
957- self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
958-
959- def test_error(self):
960- # A ValueError is raised if the bundle/jujucharms URL is not valid.
961- bad_urls = (
962- 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such',
963- 'bundle:~user/name', 'bundle:~user/basket/revision/name',
964- 'bundle:basket/name//', 'bundle:basket.name/bundle.name',
965- settings.JUJUCHARMS_BUNDLE_URL,
966- settings.JUJUCHARMS_BUNDLE_URL + 'bad',
967- settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such',
968- settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/',
969- settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error',
970- 'https://jujucharms.com/charms/mediawiki/simple/',
971- )
972- for url in bad_urls:
973- with self.assert_value_error('invalid bundle URL: {}'.format(url)):
974- utils.convert_bundle_url(url)
975-
976-
977 class TestGetQuickstartBanner(unittest.TestCase):
978
979 def patch_datetime(self):
980@@ -321,79 +168,6 @@
981 self.assertEqual(expected, obtained)
982
983
984-class TestGetServiceInfo(helpers.WatcherDataTestsMixin, unittest.TestCase):
985-
986- def test_service_and_unit(self):
987- # The data about the given service and unit is correctly returned.
988- service_change = self.make_service_change()
989- unit_change = self.make_unit_change()
990- status = [service_change, unit_change]
991- expected = (service_change[2], unit_change[2])
992- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
993-
994- def test_service_only(self):
995- # The data about the given service without units is correctly returned.
996- service_change = self.make_service_change()
997- status = [service_change]
998- expected = (service_change[2], None)
999- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1000-
1001- def test_service_removed(self):
1002- # A tuple (None, None) is returned if the service is being removed.
1003- status = [
1004- self.make_service_change(action='remove'),
1005- self.make_unit_change(),
1006- ]
1007- expected = (None, None)
1008- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1009-
1010- def test_another_service(self):
1011- # A tuple (None, None) is returned if the service is not found.
1012- status = [
1013- self.make_service_change(data={'Name': 'another-service'}),
1014- self.make_unit_change(),
1015- ]
1016- expected = (None, None)
1017- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1018-
1019- def test_service_not_alive(self):
1020- # A tuple (None, None) is returned if the service is not alive.
1021- status = [
1022- self.make_service_change(data={'Life': 'dying'}),
1023- self.make_unit_change(),
1024- ]
1025- expected = (None, None)
1026- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1027-
1028- def test_unit_removed(self):
1029- # The unit data is not returned if the unit is being removed.
1030- service_change = self.make_service_change()
1031- status = [service_change, self.make_unit_change(action='remove')]
1032- expected = (service_change[2], None)
1033- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1034-
1035- def test_another_unit(self):
1036- # The unit data is not returned if the unit belongs to another service.
1037- service_change = self.make_service_change()
1038- status = [
1039- service_change,
1040- self.make_unit_change(data={'Service': 'another-service'}),
1041- ]
1042- expected = (service_change[2], None)
1043- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1044-
1045- def test_no_services(self):
1046- # A tuple (None, None) is returned no services are found.
1047- status = [self.make_unit_change()]
1048- expected = (None, None)
1049- self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
1050-
1051- def test_no_entities(self):
1052- # A tuple (None, None) is returned no entities are found.
1053- expected = (None, None)
1054- self.assertEqual(expected, utils.get_service_info([], 'my-gui'))
1055-
1056-
1057 class TestGetUbuntuCodename(helpers.CallTestsMixin, unittest.TestCase):
1058
1059 def test_codename(self):
1060@@ -461,177 +235,6 @@
1061 self.assertFalse(os.path.exists(path))
1062
1063
1064-class TestParseBundle(
1065- helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
1066- unittest.TestCase):
1067-
1068- def assert_bundle(
1069- self, expected_name, expected_services, contents,
1070- bundle_name=None):
1071- """Ensure parsing the given contents returns the expected values."""
1072- name, services = utils.parse_bundle(contents, bundle_name=bundle_name)
1073- self.assertEqual(expected_name, name)
1074- self.assertEqual(set(expected_services), set(services))
1075-
1076- def test_invalid_yaml(self):
1077- # A ValueError is raised if the bundle contents are not a valid YAML.
1078- with self.assertRaises(ValueError) as context_manager:
1079- utils.parse_bundle(':')
1080- expected = 'unable to parse the bundle'
1081- self.assertIn(expected, bytes(context_manager.exception))
1082-
1083- def test_yaml_invalid_type(self):
1084- # A ValueError is raised if the bundle contents are not well formed.
1085- with self.assert_value_error('invalid YAML contents: a-string'):
1086- utils.parse_bundle('a-string')
1087-
1088- def test_yaml_invalid_bundle_data(self):
1089- # A ValueError is raised if bundles are not well formed.
1090- contents = yaml.safe_dump({'mybundle': 'not valid'})
1091- expected = 'invalid YAML contents: {mybundle: not valid}\n'
1092- with self.assert_value_error(expected):
1093- utils.parse_bundle(contents)
1094-
1095- def test_yaml_no_service(self):
1096- # A ValueError is raised if bundles do not include services.
1097- contents = yaml.safe_dump({'mybundle': {}})
1098- expected = 'invalid YAML contents: mybundle: {}\n'
1099- with self.assert_value_error(expected):
1100- utils.parse_bundle(contents)
1101-
1102- def test_yaml_none_bundle_services(self):
1103- # A ValueError is raised if services are None.
1104- contents = yaml.safe_dump({'mybundle': {'services': None}})
1105- expected = 'invalid YAML contents: mybundle: {services: null}\n'
1106- with self.assert_value_error(expected):
1107- utils.parse_bundle(contents)
1108-
1109- def test_yaml_invalid_bundle_services_type(self):
1110- # A ValueError is raised if services have an invalid type.
1111- contents = yaml.safe_dump({'mybundle': {'services': 42}})
1112- expected = 'invalid YAML contents: mybundle: {services: 42}\n'
1113- with self.assert_value_error(expected):
1114- utils.parse_bundle(contents)
1115-
1116- def test_yaml_no_bundles(self):
1117- # A ValueError is raised if the bundle contents are empty.
1118- with self.assert_value_error('no bundles found'):
1119- utils.parse_bundle(yaml.safe_dump({}))
1120-
1121- def test_bundle_name_not_specified(self):
1122- # A ValueError is raised if the bundle name is not specified and the
1123- # contents contain more than one bundle.
1124- expected = ('multiple bundles found (bundle1, bundle2) '
1125- 'but no bundle name specified')
1126- with self.assert_value_error(expected):
1127- utils.parse_bundle(self.valid_bundle)
1128-
1129- def test_bundle_name_not_found(self):
1130- # A ValueError is raised if the given bundle is not found in the file.
1131- expected = ('bundle no-such not found in the provided list of bundles '
1132- '(bundle1, bundle2)')
1133- with self.assert_value_error(expected):
1134- utils.parse_bundle(self.valid_bundle, 'no-such')
1135-
1136- def test_no_services(self):
1137- # A ValueError is raised if the specified bundle does not contain
1138- # services.
1139- contents = yaml.safe_dump({'mybundle': {'services': {}}})
1140- expected = 'bundle mybundle does not include any services'
1141- with self.assert_value_error(expected):
1142- utils.parse_bundle(contents)
1143-
1144- def test_yaml_gui_in_services(self):
1145- # A ValueError is raised if the bundle contains juju-gui.
1146- contents = yaml.safe_dump({
1147- 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}},
1148- })
1149- expected = 'bundle mybundle contains an instance of juju-gui. ' \
1150- 'quickstart will install the latest version of the Juju GUI ' \
1151- 'automatically, please remove juju-gui from the bundle.'
1152- with self.assert_value_error(expected):
1153- utils.parse_bundle(contents)
1154-
1155- def test_success_no_name(self):
1156- # The function succeeds when an implicit bundle name is used.
1157- contents = yaml.safe_dump({
1158- 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
1159- })
1160- self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
1161-
1162- def test_success_multiple_bundles(self):
1163- # The function succeeds with multiple bundles.
1164- self.assert_bundle(
1165- 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2')
1166-
1167- def test_success_json(self):
1168- # Since JSON is a subset of YAML, the function also support JSON
1169- # encoded bundles.
1170- contents = json.dumps({
1171- 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
1172- })
1173- self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
1174-
1175-
1176-class TestParseStatusOutput(helpers.ValueErrorTestsMixin, unittest.TestCase):
1177-
1178- def test_invalid_yaml(self):
1179- # A ValueError is raised if the output is not a valid YAML.
1180- with self.assertRaises(ValueError) as context_manager:
1181- utils.parse_status_output(':')
1182- expected = 'unable to parse the output'
1183- self.assertIn(expected, bytes(context_manager.exception))
1184-
1185- def test_invalid_yaml_contents(self):
1186- # A ValueError is raised if the output is not well formed.
1187- with self.assert_value_error('invalid YAML contents: a-string'):
1188- utils.parse_status_output('a-string')
1189-
1190- def test_no_agent_state(self):
1191- # A ValueError is raised if the agent-state is not found in the YAML.
1192- data = {
1193- 'machines': {
1194- '0': {'agent-version': '1.17.0.1'},
1195- },
1196- }
1197- expected = 'machines:0:agent-state not found in {}'.format(bytes(data))
1198- with self.assert_value_error(expected):
1199- utils.get_agent_state(yaml.safe_dump(data))
1200-
1201- def test_success_agent_state(self):
1202- # The agent state is correctly returned.
1203- output = yaml.safe_dump({
1204- 'machines': {
1205- '0': {'agent-version': '1.17.0.1', 'agent-state': 'started'},
1206- },
1207- })
1208- agent_state = utils.get_agent_state(output)
1209- self.assertEqual('started', agent_state)
1210-
1211- def test_no_bootstrap_node_series(self):
1212- # A ValueError is raised if the series is not found in the YAML.
1213- data = {
1214- 'machines': {
1215- '0': {'agent-version': '1.17.0.1'},
1216- },
1217- }
1218- expected = 'machines:0:series not found in {}'.format(bytes(data))
1219- with self.assert_value_error(expected):
1220- utils.get_bootstrap_node_series(yaml.safe_dump(data))
1221-
1222- def test_success_bootstrap_node_series(self):
1223- # The bootstrap node series is correctly returned.
1224- output = yaml.safe_dump({
1225- 'machines': {
1226- '0': {'agent-version': '1.17.0.1',
1227- 'agent-state': 'started',
1228- 'series': 'zydeco'},
1229- },
1230- })
1231- bsn_series = utils.get_bootstrap_node_series(output)
1232- self.assertEqual('zydeco', bsn_series)
1233-
1234-
1235 class TestRunOnce(unittest.TestCase):
1236
1237 def setUp(self):
1238
1239=== modified file 'quickstart/utils.py'
1240--- quickstart/utils.py 2014-11-11 19:08:57 +0000
1241+++ quickstart/utils.py 2015-02-09 13:39:50 +0000
1242@@ -14,41 +14,22 @@
1243 # You should have received a copy of the GNU Affero General Public License
1244 # along with this program. If not, see <http://www.gnu.org/licenses/>.
1245
1246-"""Juju Quickstart utility functions and classes."""
1247+"""Juju Quickstart general purpose utility functions and classes."""
1248
1249 from __future__ import (
1250 print_function,
1251 unicode_literals,
1252 )
1253
1254-import collections
1255 import datetime
1256 import errno
1257 import functools
1258 import logging
1259 import os
1260 import pipes
1261-import re
1262 import subprocess
1263
1264 import quickstart
1265-from quickstart import (
1266- serializers,
1267- settings,
1268-)
1269-from quickstart.models import charms
1270-
1271-
1272-# Compile the regular expression used to parse bundle URLs.
1273-_bundle_expression = re.compile(r"""
1274- # Bundle schema or bundle URL namespace on jujucharms.com.
1275- ^(?:bundle:|{})
1276- (?:~([-\w]+)/)? # Optional user name.
1277- ([-\w]+)/ # Basket name.
1278- (?:(\d+)/)? # Optional bundle revision number.
1279- ([-\w]+) # Bundle name.
1280- /?$ # Optional trailing slash.
1281-""".format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE)
1282
1283
1284 def add_apt_repository(repository):
1285@@ -101,51 +82,6 @@
1286 return retcode, output.decode('utf-8'), error.decode('utf-8')
1287
1288
1289-def parse_gui_charm_url(charm_url):
1290- """Parse the given charm URL.
1291-
1292- Check if the charm looks like a Juju GUI charm.
1293- Print (to stdout or to logs) info and warnings about the charm URL.
1294-
1295- Return the parsed charm object as an instance of
1296- quickstart.models.charms.Charm.
1297- """
1298- print('charm URL: {}'.format(charm_url))
1299- charm = charms.Charm.from_url(charm_url)
1300- charm_name = settings.JUJU_GUI_CHARM_NAME
1301- if charm.name != charm_name:
1302- # This does not seem to be a Juju GUI charm.
1303- logging.warn(
1304- 'unexpected URL for the {} charm: '
1305- 'the service may not work as expected'.format(charm_name))
1306- return charm
1307- if charm.user or charm.is_local():
1308- # This is not the official Juju GUI charm.
1309- logging.warn('using a customized {} charm'.format(charm_name))
1310- elif charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]:
1311- # This is the official Juju GUI charm, but it is outdated.
1312- logging.warn(
1313- 'charm is outdated and may not support bundle deployments')
1314- return charm
1315-
1316-
1317-def convert_bundle_url(bundle_url):
1318- """Return the equivalent YAML HTTPS location for the given bundle URL.
1319-
1320- Raise a ValueError if the given URL is not a valid bundle URL.
1321- """
1322- match = _bundle_expression.match(bundle_url)
1323- if match is None:
1324- msg = 'invalid bundle URL: {}'.format(bundle_url)
1325- raise ValueError(msg.encode('utf-8'))
1326- user, basket, revision, name = match.groups()
1327- user_part = '~charmers/' if user is None else '~{}/'.format(user)
1328- revision_part = '' if revision is None else '{}/'.format(revision)
1329- bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name)
1330- return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id),
1331- bundle_id)
1332-
1333-
1334 def get_quickstart_banner():
1335 """Return a quickstart banner suitable for being included in files.
1336
1337@@ -162,31 +98,6 @@
1338 '# at {} UTC.\n\n'.format(version, formatted_date))
1339
1340
1341-def get_service_info(status, service_name):
1342- """Retrieve information on the given service and on its first alive unit.
1343-
1344- Return a tuple containing two values: (service data, unit data).
1345- Each value can be:
1346- - a dictionary of data about the given entity (service or unit) as
1347- returned by the Juju watcher;
1348- - None, if the entity is not present in the Juju environment.
1349- If the service data is None, the unit data is always None.
1350- """
1351- services = [
1352- data for entity, action, data in status if
1353- (entity == 'service') and (action != 'remove') and
1354- (data['Name'] == service_name) and (data['Life'] == 'alive')
1355- ]
1356- if not services:
1357- return None, None
1358- units = [
1359- data for entity, action, data in status if
1360- entity == 'unit' and action != 'remove' and
1361- data['Service'] == service_name
1362- ]
1363- return services[0], units[0] if units else None
1364-
1365-
1366 def get_ubuntu_codename():
1367 """Return the codename of the current Ubuntu release (e.g. "trusty").
1368
1369@@ -213,112 +124,6 @@
1370 raise
1371
1372
1373-def parse_bundle(bundle_yaml, bundle_name=None):
1374- """Parse the provided bundle YAML encoded contents.
1375-
1376- Since a valid JSON is a subset of YAML this function can be used also to
1377- parse JSON encoded contents.
1378-
1379- Return a tuple containing the bundle name and the list of services included
1380- in the bundle.
1381-
1382- Raise a ValueError if:
1383- - the bundle YAML contents are not parsable by YAML;
1384- - the YAML contents are not properly structured;
1385- - the bundle name is specified but not included in the bundle file;
1386- - the bundle name is not specified and the bundle file includes more than
1387- one bundle;
1388- - the bundle does not include services.
1389- """
1390- # Parse the bundle file.
1391- try:
1392- bundles = serializers.yaml_load(bundle_yaml)
1393- except Exception as err:
1394- msg = b'unable to parse the bundle: {}'.format(err)
1395- raise ValueError(msg)
1396- # Ensure the bundle file is well formed and contains at least one bundle.
1397- if not isinstance(bundles, collections.Mapping):
1398- msg = 'invalid YAML contents: {}'.format(bundle_yaml)
1399- raise ValueError(msg.encode('utf-8'))
1400- try:
1401- name_services_map = dict(
1402- (key, value['services'].keys())
1403- for key, value in bundles.items()
1404- )
1405- except (AttributeError, KeyError, TypeError):
1406- msg = 'invalid YAML contents: {}'.format(bundle_yaml)
1407- raise ValueError(msg.encode('utf-8'))
1408- if not name_services_map:
1409- raise ValueError(b'no bundles found')
1410- # Retrieve the bundle name and services.
1411- if bundle_name is None:
1412- if len(name_services_map) > 1:
1413- msg = 'multiple bundles found ({}) but no bundle name specified'
1414- bundle_names = ', '.join(sorted(name_services_map.keys()))
1415- raise ValueError(msg.format(bundle_names).encode('utf-8'))
1416- bundle_name, bundle_services = name_services_map.items()[0]
1417- else:
1418- bundle_services = name_services_map.get(bundle_name)
1419- if bundle_services is None:
1420- msg = 'bundle {} not found in the provided list of bundles ({})'
1421- bundle_names = ', '.join(sorted(name_services_map.keys()))
1422- raise ValueError(
1423- msg.format(bundle_name, bundle_names).encode('utf-8'))
1424- if not bundle_services:
1425- msg = 'bundle {} does not include any services'.format(bundle_name)
1426- raise ValueError(msg.encode('utf-8'))
1427- if settings.JUJU_GUI_SERVICE_NAME in bundle_services:
1428- msg = ('bundle {} contains an instance of juju-gui. quickstart will '
1429- 'install the latest version of the Juju GUI automatically, '
1430- 'please remove juju-gui from the bundle.'.format(bundle_name))
1431- raise ValueError(msg.encode('utf-8'))
1432- return bundle_name, bundle_services
1433-
1434-
1435-def parse_status_output(output, keys=None):
1436- """Parse the output of juju status.
1437-
1438- Return selection specified by the keys array.
1439- Raise a ValueError if the selection cannot be retrieved.
1440- """
1441- if keys is None:
1442- keys = ['dummy']
1443- try:
1444- status = serializers.yaml_load(output)
1445- except Exception as err:
1446- raise ValueError(b'unable to parse the output: {}'.format(err))
1447-
1448- selection = status
1449- for key in keys:
1450- try:
1451- selection = selection.get(key, {})
1452- except AttributeError as err:
1453- msg = 'invalid YAML contents: {}'.format(status)
1454- raise ValueError(msg.encode('utf-8'))
1455- if selection == {}:
1456- msg = '{} not found in {}'.format(':'.join(keys), status)
1457- raise ValueError(msg.encode('utf-8'))
1458- return selection
1459-
1460-
1461-def get_agent_state(output):
1462- """Parse the output of juju status for the agent state.
1463-
1464- Return the agent state.
1465- Raise a ValueError if the agent state cannot be retrieved.
1466- """
1467- return parse_status_output(output, ['machines', '0', 'agent-state'])
1468-
1469-
1470-def get_bootstrap_node_series(output):
1471- """Parse the output of juju status for the agent state.
1472-
1473- Return the agent state.
1474- Raise a ValueError if the agent state cannot be retrieved.
1475- """
1476- return parse_status_output(output, ['machines', '0', 'series'])
1477-
1478-
1479 def get_juju_version(juju_command):
1480 """Return the current juju-core version.
1481

Subscribers

People subscribed via source and target branches