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
=== modified file 'quickstart/app.py'
--- quickstart/app.py 2015-02-09 10:38:25 +0000
+++ quickstart/app.py 2015-02-09 13:39:50 +0000
@@ -30,6 +30,7 @@
3030
31from quickstart import (31from quickstart import (
32 juju,32 juju,
33 jujutools,
33 netutils,34 netutils,
34 platform_support,35 platform_support,
35 settings,36 settings,
@@ -265,11 +266,11 @@
265 continue266 continue
266 # Ensure the state server is up and the agent is started.267 # Ensure the state server is up and the agent is started.
267 try:268 try:
268 agent_state = utils.get_agent_state(output)269 agent_state = jujutools.get_agent_state(output)
269 except ValueError:270 except ValueError:
270 continue271 continue
271 if agent_state == 'started':272 if agent_state == 'started':
272 return utils.get_bootstrap_node_series(output)273 return jujutools.get_bootstrap_node_series(output)
273 # If the agent is in an error state, there is nothing we can do, and274 # If the agent is in an error state, there is nothing we can do, and
274 # it's not useful to keep trying.275 # it's not useful to keep trying.
275 if agent_state == 'error':276 if agent_state == 'error':
@@ -408,7 +409,8 @@
408 status = env.get_status()409 status = env.get_status()
409 except jujuclient.EnvError as err:410 except jujuclient.EnvError as err:
410 raise ProgramExit('bad API response: {}'.format(err.message))411 raise ProgramExit('bad API response: {}'.format(err.message))
411 service_data, unit_data = utils.get_service_info(status, service_name)412 service_data, unit_data = jujutools.get_service_info(
413 status, service_name)
412 if service_data is None:414 if service_data is None:
413 # The service does not exist in the environment.415 # The service does not exist in the environment.
414 if charm_url is None:416 if charm_url is None:
@@ -429,7 +431,7 @@
429 # A deployed service already exists in the environment: ignore the431 # A deployed service already exists in the environment: ignore the
430 # provided charm URL and just use the already deployed charm.432 # provided charm URL and just use the already deployed charm.
431 charm_url = service_data['CharmURL']433 charm_url = service_data['CharmURL']
432 charm = utils.parse_gui_charm_url(charm_url)434 charm = jujutools.parse_gui_charm_url(charm_url)
433 # Deploy on the bootstrap node if the following conditions are satisfied:435 # Deploy on the bootstrap node if the following conditions are satisfied:
434 # - we are not using the local provider (which uses localhost);436 # - we are not using the local provider (which uses localhost);
435 # - we are not using the azure provider (in which availability sets prevent437 # - we are not using the azure provider (in which availability sets prevent
436438
=== added file 'quickstart/jujutools.py'
--- quickstart/jujutools.py 1970-01-01 00:00:00 +0000
+++ quickstart/jujutools.py 2015-02-09 13:39:50 +0000
@@ -0,0 +1,127 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2015 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Quickstart utility functions for managing Juju environments and entities."""
18
19from __future__ import (
20 print_function,
21 unicode_literals,
22)
23
24import logging
25
26from quickstart import (
27 serializers,
28 settings,
29)
30from quickstart.models import charms
31
32
33def get_service_info(status, service_name):
34 """Retrieve information on the given service and on its first alive unit.
35
36 Return a tuple containing two values: (service data, unit data).
37 Each value can be:
38 - a dictionary of data about the given entity (service or unit) as
39 returned by the Juju watcher;
40 - None, if the entity is not present in the Juju environment.
41 If the service data is None, the unit data is always None.
42 """
43 services = [
44 data for entity, action, data in status if
45 (entity == 'service') and (action != 'remove') and
46 (data['Name'] == service_name) and (data['Life'] == 'alive')
47 ]
48 if not services:
49 return None, None
50 units = [
51 data for entity, action, data in status if
52 entity == 'unit' and action != 'remove' and
53 data['Service'] == service_name
54 ]
55 return services[0], units[0] if units else None
56
57
58def parse_gui_charm_url(charm_url):
59 """Parse the given charm URL.
60
61 Check if the charm looks like a Juju GUI charm.
62 Print (to stdout or to logs) info and warnings about the charm URL.
63
64 Return the parsed charm object as an instance of
65 quickstart.models.charms.Charm.
66 """
67 print('charm URL: {}'.format(charm_url))
68 charm = charms.Charm.from_url(charm_url)
69 charm_name = settings.JUJU_GUI_CHARM_NAME
70 if charm.name != charm_name:
71 # This does not seem to be a Juju GUI charm.
72 logging.warn(
73 'unexpected URL for the {} charm: '
74 'the service may not work as expected'.format(charm_name))
75 return charm
76 if charm.user or charm.is_local():
77 # This is not the official Juju GUI charm.
78 logging.warn('using a customized {} charm'.format(charm_name))
79 elif charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]:
80 # This is the official Juju GUI charm, but it is outdated.
81 logging.warn(
82 'charm is outdated and may not support bundle deployments')
83 return charm
84
85
86def parse_status_output(output, keys=None):
87 """Parse the output of juju status.
88
89 Return selection specified by the keys array.
90 Raise a ValueError if the selection cannot be retrieved.
91 """
92 if keys is None:
93 keys = ['dummy']
94 try:
95 status = serializers.yaml_load(output)
96 except Exception as err:
97 raise ValueError(b'unable to parse the output: {}'.format(err))
98
99 selection = status
100 for key in keys:
101 try:
102 selection = selection.get(key, {})
103 except AttributeError as err:
104 msg = 'invalid YAML contents: {}'.format(status)
105 raise ValueError(msg.encode('utf-8'))
106 if selection == {}:
107 msg = '{} not found in {}'.format(':'.join(keys), status)
108 raise ValueError(msg.encode('utf-8'))
109 return selection
110
111
112def get_agent_state(output):
113 """Parse the output of juju status for the agent state.
114
115 Return the agent state.
116 Raise a ValueError if the agent state cannot be retrieved.
117 """
118 return parse_status_output(output, ['machines', '0', 'agent-state'])
119
120
121def get_bootstrap_node_series(output):
122 """Parse the output of juju status for the agent state.
123
124 Return the agent state.
125 Raise a ValueError if the agent state cannot be retrieved.
126 """
127 return parse_status_output(output, ['machines', '0', 'series'])
0128
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2015-01-30 15:27:07 +0000
+++ quickstart/manage.py 2015-02-09 13:39:50 +0000
@@ -43,6 +43,7 @@
43 views,43 views,
44)44)
45from quickstart.models import (45from quickstart.models import (
46 bundles,
46 charms,47 charms,
47 envs,48 envs,
48 jenv,49 jenv,
@@ -107,7 +108,7 @@
107 if bundle.startswith('bundle:') or bundle.startswith(jujucharms_prefix):108 if bundle.startswith('bundle:') or bundle.startswith(jujucharms_prefix):
108 # Convert "bundle:" or jujucharms.com URLs into Charmworld HTTPS ones.109 # Convert "bundle:" or jujucharms.com URLs into Charmworld HTTPS ones.
109 try:110 try:
110 bundle, bundle_id = utils.convert_bundle_url(bundle)111 bundle, bundle_id = bundles.convert_bundle_url(bundle)
111 except ValueError as err:112 except ValueError as err:
112 return parser.error('unable to open the bundle: {}'.format(err))113 return parser.error('unable to open the bundle: {}'.format(err))
113 # The next if block below will then load the bundle contents from the114 # The next if block below will then load the bundle contents from the
@@ -130,7 +131,7 @@
130 return parser.error('unable to open bundle file: {}'.format(err))131 return parser.error('unable to open bundle file: {}'.format(err))
131 # Validate the bundle.132 # Validate the bundle.
132 try:133 try:
133 bundle_name, bundle_services = utils.parse_bundle(134 bundle_name, bundle_services = bundles.parse_bundle(
134 bundle_yaml, options.bundle_name)135 bundle_yaml, options.bundle_name)
135 except ValueError as err:136 except ValueError as err:
136 return parser.error(bytes(err))137 return parser.error(bytes(err))
137138
=== added file 'quickstart/models/bundles.py'
--- quickstart/models/bundles.py 1970-01-01 00:00:00 +0000
+++ quickstart/models/bundles.py 2015-02-09 13:39:50 +0000
@@ -0,0 +1,118 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2015 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Juju Quickstart bundles management."""
18
19from __future__ import unicode_literals
20
21import collections
22import re
23
24from quickstart import (
25 serializers,
26 settings,
27)
28
29
30# Compile the regular expression used to parse bundle URLs.
31_bundle_expression = re.compile(r"""
32 # Bundle schema or bundle URL namespace on jujucharms.com.
33 ^(?:bundle:|{})
34 (?:~([-\w]+)/)? # Optional user name.
35 ([-\w]+)/ # Basket name.
36 (?:(\d+)/)? # Optional bundle revision number.
37 ([-\w]+) # Bundle name.
38 /?$ # Optional trailing slash.
39""".format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE)
40
41
42def convert_bundle_url(bundle_url):
43 """Return the equivalent YAML HTTPS location for the given bundle URL.
44
45 Raise a ValueError if the given URL is not a valid bundle URL.
46 """
47 match = _bundle_expression.match(bundle_url)
48 if match is None:
49 msg = 'invalid bundle URL: {}'.format(bundle_url)
50 raise ValueError(msg.encode('utf-8'))
51 user, basket, revision, name = match.groups()
52 user_part = '~charmers/' if user is None else '~{}/'.format(user)
53 revision_part = '' if revision is None else '{}/'.format(revision)
54 bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name)
55 return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id),
56 bundle_id)
57
58
59def parse_bundle(bundle_yaml, bundle_name=None):
60 """Parse the provided bundle YAML encoded contents.
61
62 Since a valid JSON is a subset of YAML this function can be used also to
63 parse JSON encoded contents.
64
65 Return a tuple containing the bundle name and the list of services included
66 in the bundle.
67
68 Raise a ValueError if:
69 - the bundle YAML contents are not parsable by YAML;
70 - the YAML contents are not properly structured;
71 - the bundle name is specified but not included in the bundle file;
72 - the bundle name is not specified and the bundle file includes more than
73 one bundle;
74 - the bundle does not include services.
75 """
76 # Parse the bundle file.
77 try:
78 bundles = serializers.yaml_load(bundle_yaml)
79 except Exception as err:
80 msg = b'unable to parse the bundle: {}'.format(err)
81 raise ValueError(msg)
82 # Ensure the bundle file is well formed and contains at least one bundle.
83 if not isinstance(bundles, collections.Mapping):
84 msg = 'invalid YAML contents: {}'.format(bundle_yaml)
85 raise ValueError(msg.encode('utf-8'))
86 try:
87 name_services_map = dict(
88 (key, value['services'].keys())
89 for key, value in bundles.items()
90 )
91 except (AttributeError, KeyError, TypeError):
92 msg = 'invalid YAML contents: {}'.format(bundle_yaml)
93 raise ValueError(msg.encode('utf-8'))
94 if not name_services_map:
95 raise ValueError(b'no bundles found')
96 # Retrieve the bundle name and services.
97 if bundle_name is None:
98 if len(name_services_map) > 1:
99 msg = 'multiple bundles found ({}) but no bundle name specified'
100 bundle_names = ', '.join(sorted(name_services_map.keys()))
101 raise ValueError(msg.format(bundle_names).encode('utf-8'))
102 bundle_name, bundle_services = name_services_map.items()[0]
103 else:
104 bundle_services = name_services_map.get(bundle_name)
105 if bundle_services is None:
106 msg = 'bundle {} not found in the provided list of bundles ({})'
107 bundle_names = ', '.join(sorted(name_services_map.keys()))
108 raise ValueError(
109 msg.format(bundle_name, bundle_names).encode('utf-8'))
110 if not bundle_services:
111 msg = 'bundle {} does not include any services'.format(bundle_name)
112 raise ValueError(msg.encode('utf-8'))
113 if settings.JUJU_GUI_SERVICE_NAME in bundle_services:
114 msg = ('bundle {} contains an instance of juju-gui. quickstart will '
115 'install the latest version of the Juju GUI automatically, '
116 'please remove juju-gui from the bundle.'.format(bundle_name))
117 raise ValueError(msg.encode('utf-8'))
118 return bundle_name, bundle_services
0119
=== added file 'quickstart/tests/models/test_bundles.py'
--- quickstart/tests/models/test_bundles.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/models/test_bundles.py 2015-02-09 13:39:50 +0000
@@ -0,0 +1,254 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2015 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the Juju Quickstart bundles management."""
18
19from __future__ import unicode_literals
20
21import json
22import unittest
23
24import yaml
25
26from quickstart import settings
27from quickstart.models import bundles
28from quickstart.tests import helpers
29
30
31class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):
32
33 def test_full_bundle_url(self):
34 # The HTTPS location to the YAML contents is correctly returned.
35 bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki'
36 url, bundle_id = bundles.convert_bundle_url(bundle_url)
37 self.assertEqual(
38 'https://manage.jujucharms.com'
39 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
40 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
41
42 def test_bundle_url_right_strip(self):
43 # The trailing slash in the bundle URL is removed.
44 bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/'
45 url, bundle_id = bundles.convert_bundle_url(bundle_url)
46 self.assertEqual(
47 'https://manage.jujucharms.com'
48 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
49 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
50
51 def test_bundle_url_no_revision(self):
52 # The bundle revision is optional.
53 bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple'
54 url, bundle_id = bundles.convert_bundle_url(bundle_url)
55 self.assertEqual(
56 'https://manage.jujucharms.com'
57 '/bundle/~myuser/wiki-bundle/wiki-simple/json', url)
58 self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id)
59
60 def test_bundle_url_no_user(self):
61 # If the bundle user is not specified, the bundle is assumed to be
62 # promulgated and owned by "charmers".
63 bundle_url = 'bundle:wiki-bundle/1/wiki'
64 url, bundle_id = bundles.convert_bundle_url(bundle_url)
65 self.assertEqual(
66 'https://manage.jujucharms.com'
67 '/bundle/~charmers/wiki-bundle/1/wiki/json', url)
68 self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id)
69
70 def test_bundle_url_short_form(self):
71 # A promulgated bundle URL can just include the basket and the name.
72 bundle_url = 'bundle:wiki-bundle/wiki'
73 url, bundle_id = bundles.convert_bundle_url(bundle_url)
74 self.assertEqual(
75 'https://manage.jujucharms.com'
76 '/bundle/~charmers/wiki-bundle/wiki/json', url)
77 self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
78
79 def test_full_jujucharms_url(self):
80 # The HTTPS location to the YAML contents is correctly returned.
81 url, bundle_id = bundles.convert_bundle_url(
82 settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki')
83 self.assertEqual(
84 'https://manage.jujucharms.com'
85 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
86 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
87
88 def test_jujucharms_url_right_strip(self):
89 # The trailing slash in the jujucharms URL is removed.
90 url, bundle_id = bundles.convert_bundle_url(
91 settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/')
92 self.assertEqual(
93 'https://manage.jujucharms.com'
94 '/bundle/~charmers/mediawiki/6/scalable/json', url)
95 self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id)
96
97 def test_jujucharms_url_no_revision(self):
98 # The bundle revision is optional.
99 url, bundle_id = bundles.convert_bundle_url(
100 settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/')
101 self.assertEqual(
102 'https://manage.jujucharms.com'
103 '/bundle/~myuser/wiki/wiki-simple/json', url)
104 self.assertEqual('~myuser/wiki/wiki-simple', bundle_id)
105
106 def test_jujucharms_url_no_user(self):
107 # If the bundle user is not specified, the bundle is assumed to be
108 # promulgated and owned by "charmers".
109 url, bundle_id = bundles.convert_bundle_url(
110 settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/')
111 self.assertEqual(
112 'https://manage.jujucharms.com'
113 '/bundle/~charmers/mediawiki/42/single/json', url)
114 self.assertEqual('~charmers/mediawiki/42/single', bundle_id)
115
116 def test_jujucharms_url_short_form(self):
117 # A jujucharms URL for a promulgated bundle can just include the basket
118 # and the name.
119 url, bundle_id = bundles.convert_bundle_url(
120 settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/')
121 self.assertEqual(
122 'https://manage.jujucharms.com'
123 '/bundle/~charmers/wiki-bundle/wiki/json', url)
124 self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
125
126 def test_error(self):
127 # A ValueError is raised if the bundle/jujucharms URL is not valid.
128 bad_urls = (
129 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such',
130 'bundle:~user/name', 'bundle:~user/basket/revision/name',
131 'bundle:basket/name//', 'bundle:basket.name/bundle.name',
132 settings.JUJUCHARMS_BUNDLE_URL,
133 settings.JUJUCHARMS_BUNDLE_URL + 'bad',
134 settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such',
135 settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/',
136 settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error',
137 'https://jujucharms.com/charms/mediawiki/simple/',
138 )
139 for url in bad_urls:
140 with self.assert_value_error('invalid bundle URL: {}'.format(url)):
141 bundles.convert_bundle_url(url)
142
143
144class TestParseBundle(
145 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
146 unittest.TestCase):
147
148 def assert_bundle(
149 self, expected_name, expected_services, contents,
150 bundle_name=None):
151 """Ensure parsing the given contents returns the expected values."""
152 name, services = bundles.parse_bundle(
153 contents, bundle_name=bundle_name)
154 self.assertEqual(expected_name, name)
155 self.assertEqual(set(expected_services), set(services))
156
157 def test_invalid_yaml(self):
158 # A ValueError is raised if the bundle contents are not a valid YAML.
159 with self.assertRaises(ValueError) as context_manager:
160 bundles.parse_bundle(':')
161 expected = 'unable to parse the bundle'
162 self.assertIn(expected, bytes(context_manager.exception))
163
164 def test_yaml_invalid_type(self):
165 # A ValueError is raised if the bundle contents are not well formed.
166 with self.assert_value_error('invalid YAML contents: a-string'):
167 bundles.parse_bundle('a-string')
168
169 def test_yaml_invalid_bundle_data(self):
170 # A ValueError is raised if bundles are not well formed.
171 contents = yaml.safe_dump({'mybundle': 'not valid'})
172 expected = 'invalid YAML contents: {mybundle: not valid}\n'
173 with self.assert_value_error(expected):
174 bundles.parse_bundle(contents)
175
176 def test_yaml_no_service(self):
177 # A ValueError is raised if bundles do not include services.
178 contents = yaml.safe_dump({'mybundle': {}})
179 expected = 'invalid YAML contents: mybundle: {}\n'
180 with self.assert_value_error(expected):
181 bundles.parse_bundle(contents)
182
183 def test_yaml_none_bundle_services(self):
184 # A ValueError is raised if services are None.
185 contents = yaml.safe_dump({'mybundle': {'services': None}})
186 expected = 'invalid YAML contents: mybundle: {services: null}\n'
187 with self.assert_value_error(expected):
188 bundles.parse_bundle(contents)
189
190 def test_yaml_invalid_bundle_services_type(self):
191 # A ValueError is raised if services have an invalid type.
192 contents = yaml.safe_dump({'mybundle': {'services': 42}})
193 expected = 'invalid YAML contents: mybundle: {services: 42}\n'
194 with self.assert_value_error(expected):
195 bundles.parse_bundle(contents)
196
197 def test_yaml_no_bundles(self):
198 # A ValueError is raised if the bundle contents are empty.
199 with self.assert_value_error('no bundles found'):
200 bundles.parse_bundle(yaml.safe_dump({}))
201
202 def test_bundle_name_not_specified(self):
203 # A ValueError is raised if the bundle name is not specified and the
204 # contents contain more than one bundle.
205 expected = ('multiple bundles found (bundle1, bundle2) '
206 'but no bundle name specified')
207 with self.assert_value_error(expected):
208 bundles.parse_bundle(self.valid_bundle)
209
210 def test_bundle_name_not_found(self):
211 # A ValueError is raised if the given bundle is not found in the file.
212 expected = ('bundle no-such not found in the provided list of bundles '
213 '(bundle1, bundle2)')
214 with self.assert_value_error(expected):
215 bundles.parse_bundle(self.valid_bundle, 'no-such')
216
217 def test_no_services(self):
218 # A ValueError is raised if the specified bundle does not contain
219 # services.
220 contents = yaml.safe_dump({'mybundle': {'services': {}}})
221 expected = 'bundle mybundle does not include any services'
222 with self.assert_value_error(expected):
223 bundles.parse_bundle(contents)
224
225 def test_yaml_gui_in_services(self):
226 # A ValueError is raised if the bundle contains juju-gui.
227 contents = yaml.safe_dump({
228 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}},
229 })
230 expected = 'bundle mybundle contains an instance of juju-gui. ' \
231 'quickstart will install the latest version of the Juju GUI ' \
232 'automatically, please remove juju-gui from the bundle.'
233 with self.assert_value_error(expected):
234 bundles.parse_bundle(contents)
235
236 def test_success_no_name(self):
237 # The function succeeds when an implicit bundle name is used.
238 contents = yaml.safe_dump({
239 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
240 })
241 self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
242
243 def test_success_multiple_bundles(self):
244 # The function succeeds with multiple bundles.
245 self.assert_bundle(
246 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2')
247
248 def test_success_json(self):
249 # Since JSON is a subset of YAML, the function also support JSON
250 # encoded bundles.
251 contents = json.dumps({
252 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
253 })
254 self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
0255
=== added file 'quickstart/tests/test_jujutools.py'
--- quickstart/tests/test_jujutools.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/test_jujutools.py 2015-02-09 13:39:50 +0000
@@ -0,0 +1,204 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2015 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the Quickstart Juju utilities and helpers."""
18
19from __future__ import unicode_literals
20
21import unittest
22
23import mock
24import yaml
25
26from quickstart import jujutools
27from quickstart.models import charms
28from quickstart.tests import helpers
29
30
31class TestGetServiceInfo(helpers.WatcherDataTestsMixin, unittest.TestCase):
32
33 def test_service_and_unit(self):
34 # The data about the given service and unit is correctly returned.
35 service_change = self.make_service_change()
36 unit_change = self.make_unit_change()
37 status = [service_change, unit_change]
38 expected = (service_change[2], unit_change[2])
39 self.assertEqual(
40 expected, jujutools.get_service_info(status, 'my-gui'))
41
42 def test_service_only(self):
43 # The data about the given service without units is correctly returned.
44 service_change = self.make_service_change()
45 status = [service_change]
46 expected = (service_change[2], None)
47 self.assertEqual(
48 expected, jujutools.get_service_info(status, 'my-gui'))
49
50 def test_service_removed(self):
51 # A tuple (None, None) is returned if the service is being removed.
52 status = [
53 self.make_service_change(action='remove'),
54 self.make_unit_change(),
55 ]
56 expected = (None, None)
57 self.assertEqual(
58 expected, jujutools.get_service_info(status, 'my-gui'))
59
60 def test_another_service(self):
61 # A tuple (None, None) is returned if the service is not found.
62 status = [
63 self.make_service_change(data={'Name': 'another-service'}),
64 self.make_unit_change(),
65 ]
66 expected = (None, None)
67 self.assertEqual(
68 expected, jujutools.get_service_info(status, 'my-gui'))
69
70 def test_service_not_alive(self):
71 # A tuple (None, None) is returned if the service is not alive.
72 status = [
73 self.make_service_change(data={'Life': 'dying'}),
74 self.make_unit_change(),
75 ]
76 expected = (None, None)
77 self.assertEqual(
78 expected, jujutools.get_service_info(status, 'my-gui'))
79
80 def test_unit_removed(self):
81 # The unit data is not returned if the unit is being removed.
82 service_change = self.make_service_change()
83 status = [service_change, self.make_unit_change(action='remove')]
84 expected = (service_change[2], None)
85 self.assertEqual(
86 expected, jujutools.get_service_info(status, 'my-gui'))
87
88 def test_another_unit(self):
89 # The unit data is not returned if the unit belongs to another service.
90 service_change = self.make_service_change()
91 status = [
92 service_change,
93 self.make_unit_change(data={'Service': 'another-service'}),
94 ]
95 expected = (service_change[2], None)
96 self.assertEqual(
97 expected, jujutools.get_service_info(status, 'my-gui'))
98
99 def test_no_services(self):
100 # A tuple (None, None) is returned no services are found.
101 status = [self.make_unit_change()]
102 expected = (None, None)
103 self.assertEqual(
104 expected, jujutools.get_service_info(status, 'my-gui'))
105
106 def test_no_entities(self):
107 # A tuple (None, None) is returned no entities are found.
108 expected = (None, None)
109 self.assertEqual(expected, jujutools.get_service_info([], 'my-gui'))
110
111
112@mock.patch('__builtin__.print', mock.Mock())
113class TestParseGuiCharmUrl(unittest.TestCase):
114
115 def test_charm_instance_returned(self):
116 # A charm instance is correctly returned.
117 charm = jujutools.parse_gui_charm_url('cs:trusty/juju-gui-42')
118 self.assertIsInstance(charm, charms.Charm)
119 self.assertEqual('cs:trusty/juju-gui-42', charm.url())
120
121 def test_customized(self):
122 # A customized charm URL is properly logged.
123 expected = 'using a customized juju-gui charm'
124 with helpers.assert_logs([expected], level='warn'):
125 jujutools.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')
126
127 def test_outdated(self):
128 # An outdated charm URL is properly logged.
129 expected = 'charm is outdated and may not support bundle deployments'
130 with helpers.assert_logs([expected], level='warn'):
131 jujutools.parse_gui_charm_url('cs:precise/juju-gui-1')
132
133 def test_unexpected(self):
134 # An unexpected charm URL is properly logged.
135 expected = (
136 'unexpected URL for the juju-gui charm: the service may not work '
137 'as expected')
138 with helpers.assert_logs([expected], level='warn'):
139 jujutools.parse_gui_charm_url('cs:precise/another-gui-42')
140
141 def test_official(self):
142 # No warnings are logged if an up to date charm is passed.
143 with mock.patch('logging.warn') as mock_warn:
144 jujutools.parse_gui_charm_url('cs:precise/juju-gui-100')
145 self.assertFalse(mock_warn.called)
146
147
148class TestParseStatusOutput(helpers.ValueErrorTestsMixin, unittest.TestCase):
149
150 def test_invalid_yaml(self):
151 # A ValueError is raised if the output is not a valid YAML.
152 with self.assertRaises(ValueError) as context_manager:
153 jujutools.parse_status_output(':')
154 expected = 'unable to parse the output'
155 self.assertIn(expected, bytes(context_manager.exception))
156
157 def test_invalid_yaml_contents(self):
158 # A ValueError is raised if the output is not well formed.
159 with self.assert_value_error('invalid YAML contents: a-string'):
160 jujutools.parse_status_output('a-string')
161
162 def test_no_agent_state(self):
163 # A ValueError is raised if the agent-state is not found in the YAML.
164 data = {
165 'machines': {
166 '0': {'agent-version': '1.17.0.1'},
167 },
168 }
169 expected = 'machines:0:agent-state not found in {}'.format(bytes(data))
170 with self.assert_value_error(expected):
171 jujutools.get_agent_state(yaml.safe_dump(data))
172
173 def test_success_agent_state(self):
174 # The agent state is correctly returned.
175 output = yaml.safe_dump({
176 'machines': {
177 '0': {'agent-version': '1.17.0.1', 'agent-state': 'started'},
178 },
179 })
180 agent_state = jujutools.get_agent_state(output)
181 self.assertEqual('started', agent_state)
182
183 def test_no_bootstrap_node_series(self):
184 # A ValueError is raised if the series is not found in the YAML.
185 data = {
186 'machines': {
187 '0': {'agent-version': '1.17.0.1'},
188 },
189 }
190 expected = 'machines:0:series not found in {}'.format(bytes(data))
191 with self.assert_value_error(expected):
192 jujutools.get_bootstrap_node_series(yaml.safe_dump(data))
193
194 def test_success_bootstrap_node_series(self):
195 # The bootstrap node series is correctly returned.
196 output = yaml.safe_dump({
197 'machines': {
198 '0': {'agent-version': '1.17.0.1',
199 'agent-state': 'started',
200 'series': 'zydeco'},
201 },
202 })
203 bsn_series = jujutools.get_bootstrap_node_series(output)
204 self.assertEqual('zydeco', bsn_series)
0205
=== modified file 'quickstart/tests/test_utils.py'
--- quickstart/tests/test_utils.py 2014-11-11 19:08:57 +0000
+++ quickstart/tests/test_utils.py 2015-02-09 13:39:50 +0000
@@ -19,21 +19,17 @@
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import datetime21import datetime
22import json
23import os22import os
24import shutil23import shutil
25import tempfile24import tempfile
26import unittest25import unittest
2726
28import mock27import mock
29import yaml
3028
31from quickstart import (29from quickstart import (
32 get_version,30 get_version,
33 settings,
34 utils,31 utils,
35)32)
36from quickstart.models import charms
37from quickstart.tests import helpers33from quickstart.tests import helpers
3834
3935
@@ -153,155 +149,6 @@
153 utils.call('echo', 'we are the borg!')149 utils.call('echo', 'we are the borg!')
154150
155151
156@mock.patch('__builtin__.print', mock.Mock())
157class TestParseGuiCharmUrl(unittest.TestCase):
158
159 def test_charm_instance_returned(self):
160 # A charm instance is correctly returned.
161 charm = utils.parse_gui_charm_url('cs:trusty/juju-gui-42')
162 self.assertIsInstance(charm, charms.Charm)
163 self.assertEqual('cs:trusty/juju-gui-42', charm.url())
164
165 def test_customized(self):
166 # A customized charm URL is properly logged.
167 expected = 'using a customized juju-gui charm'
168 with helpers.assert_logs([expected], level='warn'):
169 utils.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')
170
171 def test_outdated(self):
172 # An outdated charm URL is properly logged.
173 expected = 'charm is outdated and may not support bundle deployments'
174 with helpers.assert_logs([expected], level='warn'):
175 utils.parse_gui_charm_url('cs:precise/juju-gui-1')
176
177 def test_unexpected(self):
178 # An unexpected charm URL is properly logged.
179 expected = (
180 'unexpected URL for the juju-gui charm: the service may not work '
181 'as expected')
182 with helpers.assert_logs([expected], level='warn'):
183 utils.parse_gui_charm_url('cs:precise/another-gui-42')
184
185 def test_official(self):
186 # No warnings are logged if an up to date charm is passed.
187 with mock.patch('logging.warn') as mock_warn:
188 utils.parse_gui_charm_url('cs:precise/juju-gui-100')
189 self.assertFalse(mock_warn.called)
190
191
192class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):
193
194 def test_full_bundle_url(self):
195 # The HTTPS location to the YAML contents is correctly returned.
196 bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki'
197 url, bundle_id = utils.convert_bundle_url(bundle_url)
198 self.assertEqual(
199 'https://manage.jujucharms.com'
200 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
201 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
202
203 def test_bundle_url_right_strip(self):
204 # The trailing slash in the bundle URL is removed.
205 bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/'
206 url, bundle_id = utils.convert_bundle_url(bundle_url)
207 self.assertEqual(
208 'https://manage.jujucharms.com'
209 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
210 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
211
212 def test_bundle_url_no_revision(self):
213 # The bundle revision is optional.
214 bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple'
215 url, bundle_id = utils.convert_bundle_url(bundle_url)
216 self.assertEqual(
217 'https://manage.jujucharms.com'
218 '/bundle/~myuser/wiki-bundle/wiki-simple/json', url)
219 self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id)
220
221 def test_bundle_url_no_user(self):
222 # If the bundle user is not specified, the bundle is assumed to be
223 # promulgated and owned by "charmers".
224 bundle_url = 'bundle:wiki-bundle/1/wiki'
225 url, bundle_id = utils.convert_bundle_url(bundle_url)
226 self.assertEqual(
227 'https://manage.jujucharms.com'
228 '/bundle/~charmers/wiki-bundle/1/wiki/json', url)
229 self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id)
230
231 def test_bundle_url_short_form(self):
232 # A promulgated bundle URL can just include the basket and the name.
233 bundle_url = 'bundle:wiki-bundle/wiki'
234 url, bundle_id = utils.convert_bundle_url(bundle_url)
235 self.assertEqual(
236 'https://manage.jujucharms.com'
237 '/bundle/~charmers/wiki-bundle/wiki/json', url)
238 self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
239
240 def test_full_jujucharms_url(self):
241 # The HTTPS location to the YAML contents is correctly returned.
242 url, bundle_id = utils.convert_bundle_url(
243 settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki')
244 self.assertEqual(
245 'https://manage.jujucharms.com'
246 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
247 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
248
249 def test_jujucharms_url_right_strip(self):
250 # The trailing slash in the jujucharms URL is removed.
251 url, bundle_id = utils.convert_bundle_url(
252 settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/')
253 self.assertEqual(
254 'https://manage.jujucharms.com'
255 '/bundle/~charmers/mediawiki/6/scalable/json', url)
256 self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id)
257
258 def test_jujucharms_url_no_revision(self):
259 # The bundle revision is optional.
260 url, bundle_id = utils.convert_bundle_url(
261 settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/')
262 self.assertEqual(
263 'https://manage.jujucharms.com'
264 '/bundle/~myuser/wiki/wiki-simple/json', url)
265 self.assertEqual('~myuser/wiki/wiki-simple', bundle_id)
266
267 def test_jujucharms_url_no_user(self):
268 # If the bundle user is not specified, the bundle is assumed to be
269 # promulgated and owned by "charmers".
270 url, bundle_id = utils.convert_bundle_url(
271 settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/')
272 self.assertEqual(
273 'https://manage.jujucharms.com'
274 '/bundle/~charmers/mediawiki/42/single/json', url)
275 self.assertEqual('~charmers/mediawiki/42/single', bundle_id)
276
277 def test_jujucharms_url_short_form(self):
278 # A jujucharms URL for a promulgated bundle can just include the basket
279 # and the name.
280 url, bundle_id = utils.convert_bundle_url(
281 settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/')
282 self.assertEqual(
283 'https://manage.jujucharms.com'
284 '/bundle/~charmers/wiki-bundle/wiki/json', url)
285 self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
286
287 def test_error(self):
288 # A ValueError is raised if the bundle/jujucharms URL is not valid.
289 bad_urls = (
290 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such',
291 'bundle:~user/name', 'bundle:~user/basket/revision/name',
292 'bundle:basket/name//', 'bundle:basket.name/bundle.name',
293 settings.JUJUCHARMS_BUNDLE_URL,
294 settings.JUJUCHARMS_BUNDLE_URL + 'bad',
295 settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such',
296 settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/',
297 settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error',
298 'https://jujucharms.com/charms/mediawiki/simple/',
299 )
300 for url in bad_urls:
301 with self.assert_value_error('invalid bundle URL: {}'.format(url)):
302 utils.convert_bundle_url(url)
303
304
305class TestGetQuickstartBanner(unittest.TestCase):152class TestGetQuickstartBanner(unittest.TestCase):
306153
307 def patch_datetime(self):154 def patch_datetime(self):
@@ -321,79 +168,6 @@
321 self.assertEqual(expected, obtained)168 self.assertEqual(expected, obtained)
322169
323170
324class TestGetServiceInfo(helpers.WatcherDataTestsMixin, unittest.TestCase):
325
326 def test_service_and_unit(self):
327 # The data about the given service and unit is correctly returned.
328 service_change = self.make_service_change()
329 unit_change = self.make_unit_change()
330 status = [service_change, unit_change]
331 expected = (service_change[2], unit_change[2])
332 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
333
334 def test_service_only(self):
335 # The data about the given service without units is correctly returned.
336 service_change = self.make_service_change()
337 status = [service_change]
338 expected = (service_change[2], None)
339 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
340
341 def test_service_removed(self):
342 # A tuple (None, None) is returned if the service is being removed.
343 status = [
344 self.make_service_change(action='remove'),
345 self.make_unit_change(),
346 ]
347 expected = (None, None)
348 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
349
350 def test_another_service(self):
351 # A tuple (None, None) is returned if the service is not found.
352 status = [
353 self.make_service_change(data={'Name': 'another-service'}),
354 self.make_unit_change(),
355 ]
356 expected = (None, None)
357 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
358
359 def test_service_not_alive(self):
360 # A tuple (None, None) is returned if the service is not alive.
361 status = [
362 self.make_service_change(data={'Life': 'dying'}),
363 self.make_unit_change(),
364 ]
365 expected = (None, None)
366 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
367
368 def test_unit_removed(self):
369 # The unit data is not returned if the unit is being removed.
370 service_change = self.make_service_change()
371 status = [service_change, self.make_unit_change(action='remove')]
372 expected = (service_change[2], None)
373 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
374
375 def test_another_unit(self):
376 # The unit data is not returned if the unit belongs to another service.
377 service_change = self.make_service_change()
378 status = [
379 service_change,
380 self.make_unit_change(data={'Service': 'another-service'}),
381 ]
382 expected = (service_change[2], None)
383 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
384
385 def test_no_services(self):
386 # A tuple (None, None) is returned no services are found.
387 status = [self.make_unit_change()]
388 expected = (None, None)
389 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
390
391 def test_no_entities(self):
392 # A tuple (None, None) is returned no entities are found.
393 expected = (None, None)
394 self.assertEqual(expected, utils.get_service_info([], 'my-gui'))
395
396
397class TestGetUbuntuCodename(helpers.CallTestsMixin, unittest.TestCase):171class TestGetUbuntuCodename(helpers.CallTestsMixin, unittest.TestCase):
398172
399 def test_codename(self):173 def test_codename(self):
@@ -461,177 +235,6 @@
461 self.assertFalse(os.path.exists(path))235 self.assertFalse(os.path.exists(path))
462236
463237
464class TestParseBundle(
465 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
466 unittest.TestCase):
467
468 def assert_bundle(
469 self, expected_name, expected_services, contents,
470 bundle_name=None):
471 """Ensure parsing the given contents returns the expected values."""
472 name, services = utils.parse_bundle(contents, bundle_name=bundle_name)
473 self.assertEqual(expected_name, name)
474 self.assertEqual(set(expected_services), set(services))
475
476 def test_invalid_yaml(self):
477 # A ValueError is raised if the bundle contents are not a valid YAML.
478 with self.assertRaises(ValueError) as context_manager:
479 utils.parse_bundle(':')
480 expected = 'unable to parse the bundle'
481 self.assertIn(expected, bytes(context_manager.exception))
482
483 def test_yaml_invalid_type(self):
484 # A ValueError is raised if the bundle contents are not well formed.
485 with self.assert_value_error('invalid YAML contents: a-string'):
486 utils.parse_bundle('a-string')
487
488 def test_yaml_invalid_bundle_data(self):
489 # A ValueError is raised if bundles are not well formed.
490 contents = yaml.safe_dump({'mybundle': 'not valid'})
491 expected = 'invalid YAML contents: {mybundle: not valid}\n'
492 with self.assert_value_error(expected):
493 utils.parse_bundle(contents)
494
495 def test_yaml_no_service(self):
496 # A ValueError is raised if bundles do not include services.
497 contents = yaml.safe_dump({'mybundle': {}})
498 expected = 'invalid YAML contents: mybundle: {}\n'
499 with self.assert_value_error(expected):
500 utils.parse_bundle(contents)
501
502 def test_yaml_none_bundle_services(self):
503 # A ValueError is raised if services are None.
504 contents = yaml.safe_dump({'mybundle': {'services': None}})
505 expected = 'invalid YAML contents: mybundle: {services: null}\n'
506 with self.assert_value_error(expected):
507 utils.parse_bundle(contents)
508
509 def test_yaml_invalid_bundle_services_type(self):
510 # A ValueError is raised if services have an invalid type.
511 contents = yaml.safe_dump({'mybundle': {'services': 42}})
512 expected = 'invalid YAML contents: mybundle: {services: 42}\n'
513 with self.assert_value_error(expected):
514 utils.parse_bundle(contents)
515
516 def test_yaml_no_bundles(self):
517 # A ValueError is raised if the bundle contents are empty.
518 with self.assert_value_error('no bundles found'):
519 utils.parse_bundle(yaml.safe_dump({}))
520
521 def test_bundle_name_not_specified(self):
522 # A ValueError is raised if the bundle name is not specified and the
523 # contents contain more than one bundle.
524 expected = ('multiple bundles found (bundle1, bundle2) '
525 'but no bundle name specified')
526 with self.assert_value_error(expected):
527 utils.parse_bundle(self.valid_bundle)
528
529 def test_bundle_name_not_found(self):
530 # A ValueError is raised if the given bundle is not found in the file.
531 expected = ('bundle no-such not found in the provided list of bundles '
532 '(bundle1, bundle2)')
533 with self.assert_value_error(expected):
534 utils.parse_bundle(self.valid_bundle, 'no-such')
535
536 def test_no_services(self):
537 # A ValueError is raised if the specified bundle does not contain
538 # services.
539 contents = yaml.safe_dump({'mybundle': {'services': {}}})
540 expected = 'bundle mybundle does not include any services'
541 with self.assert_value_error(expected):
542 utils.parse_bundle(contents)
543
544 def test_yaml_gui_in_services(self):
545 # A ValueError is raised if the bundle contains juju-gui.
546 contents = yaml.safe_dump({
547 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}},
548 })
549 expected = 'bundle mybundle contains an instance of juju-gui. ' \
550 'quickstart will install the latest version of the Juju GUI ' \
551 'automatically, please remove juju-gui from the bundle.'
552 with self.assert_value_error(expected):
553 utils.parse_bundle(contents)
554
555 def test_success_no_name(self):
556 # The function succeeds when an implicit bundle name is used.
557 contents = yaml.safe_dump({
558 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
559 })
560 self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
561
562 def test_success_multiple_bundles(self):
563 # The function succeeds with multiple bundles.
564 self.assert_bundle(
565 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2')
566
567 def test_success_json(self):
568 # Since JSON is a subset of YAML, the function also support JSON
569 # encoded bundles.
570 contents = json.dumps({
571 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
572 })
573 self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
574
575
576class TestParseStatusOutput(helpers.ValueErrorTestsMixin, unittest.TestCase):
577
578 def test_invalid_yaml(self):
579 # A ValueError is raised if the output is not a valid YAML.
580 with self.assertRaises(ValueError) as context_manager:
581 utils.parse_status_output(':')
582 expected = 'unable to parse the output'
583 self.assertIn(expected, bytes(context_manager.exception))
584
585 def test_invalid_yaml_contents(self):
586 # A ValueError is raised if the output is not well formed.
587 with self.assert_value_error('invalid YAML contents: a-string'):
588 utils.parse_status_output('a-string')
589
590 def test_no_agent_state(self):
591 # A ValueError is raised if the agent-state is not found in the YAML.
592 data = {
593 'machines': {
594 '0': {'agent-version': '1.17.0.1'},
595 },
596 }
597 expected = 'machines:0:agent-state not found in {}'.format(bytes(data))
598 with self.assert_value_error(expected):
599 utils.get_agent_state(yaml.safe_dump(data))
600
601 def test_success_agent_state(self):
602 # The agent state is correctly returned.
603 output = yaml.safe_dump({
604 'machines': {
605 '0': {'agent-version': '1.17.0.1', 'agent-state': 'started'},
606 },
607 })
608 agent_state = utils.get_agent_state(output)
609 self.assertEqual('started', agent_state)
610
611 def test_no_bootstrap_node_series(self):
612 # A ValueError is raised if the series is not found in the YAML.
613 data = {
614 'machines': {
615 '0': {'agent-version': '1.17.0.1'},
616 },
617 }
618 expected = 'machines:0:series not found in {}'.format(bytes(data))
619 with self.assert_value_error(expected):
620 utils.get_bootstrap_node_series(yaml.safe_dump(data))
621
622 def test_success_bootstrap_node_series(self):
623 # The bootstrap node series is correctly returned.
624 output = yaml.safe_dump({
625 'machines': {
626 '0': {'agent-version': '1.17.0.1',
627 'agent-state': 'started',
628 'series': 'zydeco'},
629 },
630 })
631 bsn_series = utils.get_bootstrap_node_series(output)
632 self.assertEqual('zydeco', bsn_series)
633
634
635class TestRunOnce(unittest.TestCase):238class TestRunOnce(unittest.TestCase):
636239
637 def setUp(self):240 def setUp(self):
638241
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2014-11-11 19:08:57 +0000
+++ quickstart/utils.py 2015-02-09 13:39:50 +0000
@@ -14,41 +14,22 @@
14# You should have received a copy of the GNU Affero General Public License14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.15# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
17"""Juju Quickstart utility functions and classes."""17"""Juju Quickstart general purpose utility functions and classes."""
1818
19from __future__ import (19from __future__ import (
20 print_function,20 print_function,
21 unicode_literals,21 unicode_literals,
22)22)
2323
24import collections
25import datetime24import datetime
26import errno25import errno
27import functools26import functools
28import logging27import logging
29import os28import os
30import pipes29import pipes
31import re
32import subprocess30import subprocess
3331
34import quickstart32import quickstart
35from quickstart import (
36 serializers,
37 settings,
38)
39from quickstart.models import charms
40
41
42# Compile the regular expression used to parse bundle URLs.
43_bundle_expression = re.compile(r"""
44 # Bundle schema or bundle URL namespace on jujucharms.com.
45 ^(?:bundle:|{})
46 (?:~([-\w]+)/)? # Optional user name.
47 ([-\w]+)/ # Basket name.
48 (?:(\d+)/)? # Optional bundle revision number.
49 ([-\w]+) # Bundle name.
50 /?$ # Optional trailing slash.
51""".format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE)
5233
5334
54def add_apt_repository(repository):35def add_apt_repository(repository):
@@ -101,51 +82,6 @@
101 return retcode, output.decode('utf-8'), error.decode('utf-8')82 return retcode, output.decode('utf-8'), error.decode('utf-8')
10283
10384
104def parse_gui_charm_url(charm_url):
105 """Parse the given charm URL.
106
107 Check if the charm looks like a Juju GUI charm.
108 Print (to stdout or to logs) info and warnings about the charm URL.
109
110 Return the parsed charm object as an instance of
111 quickstart.models.charms.Charm.
112 """
113 print('charm URL: {}'.format(charm_url))
114 charm = charms.Charm.from_url(charm_url)
115 charm_name = settings.JUJU_GUI_CHARM_NAME
116 if charm.name != charm_name:
117 # This does not seem to be a Juju GUI charm.
118 logging.warn(
119 'unexpected URL for the {} charm: '
120 'the service may not work as expected'.format(charm_name))
121 return charm
122 if charm.user or charm.is_local():
123 # This is not the official Juju GUI charm.
124 logging.warn('using a customized {} charm'.format(charm_name))
125 elif charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]:
126 # This is the official Juju GUI charm, but it is outdated.
127 logging.warn(
128 'charm is outdated and may not support bundle deployments')
129 return charm
130
131
132def convert_bundle_url(bundle_url):
133 """Return the equivalent YAML HTTPS location for the given bundle URL.
134
135 Raise a ValueError if the given URL is not a valid bundle URL.
136 """
137 match = _bundle_expression.match(bundle_url)
138 if match is None:
139 msg = 'invalid bundle URL: {}'.format(bundle_url)
140 raise ValueError(msg.encode('utf-8'))
141 user, basket, revision, name = match.groups()
142 user_part = '~charmers/' if user is None else '~{}/'.format(user)
143 revision_part = '' if revision is None else '{}/'.format(revision)
144 bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name)
145 return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id),
146 bundle_id)
147
148
149def get_quickstart_banner():85def get_quickstart_banner():
150 """Return a quickstart banner suitable for being included in files.86 """Return a quickstart banner suitable for being included in files.
15187
@@ -162,31 +98,6 @@
162 '# at {} UTC.\n\n'.format(version, formatted_date))98 '# at {} UTC.\n\n'.format(version, formatted_date))
16399
164100
165def get_service_info(status, service_name):
166 """Retrieve information on the given service and on its first alive unit.
167
168 Return a tuple containing two values: (service data, unit data).
169 Each value can be:
170 - a dictionary of data about the given entity (service or unit) as
171 returned by the Juju watcher;
172 - None, if the entity is not present in the Juju environment.
173 If the service data is None, the unit data is always None.
174 """
175 services = [
176 data for entity, action, data in status if
177 (entity == 'service') and (action != 'remove') and
178 (data['Name'] == service_name) and (data['Life'] == 'alive')
179 ]
180 if not services:
181 return None, None
182 units = [
183 data for entity, action, data in status if
184 entity == 'unit' and action != 'remove' and
185 data['Service'] == service_name
186 ]
187 return services[0], units[0] if units else None
188
189
190def get_ubuntu_codename():101def get_ubuntu_codename():
191 """Return the codename of the current Ubuntu release (e.g. "trusty").102 """Return the codename of the current Ubuntu release (e.g. "trusty").
192103
@@ -213,112 +124,6 @@
213 raise124 raise
214125
215126
216def parse_bundle(bundle_yaml, bundle_name=None):
217 """Parse the provided bundle YAML encoded contents.
218
219 Since a valid JSON is a subset of YAML this function can be used also to
220 parse JSON encoded contents.
221
222 Return a tuple containing the bundle name and the list of services included
223 in the bundle.
224
225 Raise a ValueError if:
226 - the bundle YAML contents are not parsable by YAML;
227 - the YAML contents are not properly structured;
228 - the bundle name is specified but not included in the bundle file;
229 - the bundle name is not specified and the bundle file includes more than
230 one bundle;
231 - the bundle does not include services.
232 """
233 # Parse the bundle file.
234 try:
235 bundles = serializers.yaml_load(bundle_yaml)
236 except Exception as err:
237 msg = b'unable to parse the bundle: {}'.format(err)
238 raise ValueError(msg)
239 # Ensure the bundle file is well formed and contains at least one bundle.
240 if not isinstance(bundles, collections.Mapping):
241 msg = 'invalid YAML contents: {}'.format(bundle_yaml)
242 raise ValueError(msg.encode('utf-8'))
243 try:
244 name_services_map = dict(
245 (key, value['services'].keys())
246 for key, value in bundles.items()
247 )
248 except (AttributeError, KeyError, TypeError):
249 msg = 'invalid YAML contents: {}'.format(bundle_yaml)
250 raise ValueError(msg.encode('utf-8'))
251 if not name_services_map:
252 raise ValueError(b'no bundles found')
253 # Retrieve the bundle name and services.
254 if bundle_name is None:
255 if len(name_services_map) > 1:
256 msg = 'multiple bundles found ({}) but no bundle name specified'
257 bundle_names = ', '.join(sorted(name_services_map.keys()))
258 raise ValueError(msg.format(bundle_names).encode('utf-8'))
259 bundle_name, bundle_services = name_services_map.items()[0]
260 else:
261 bundle_services = name_services_map.get(bundle_name)
262 if bundle_services is None:
263 msg = 'bundle {} not found in the provided list of bundles ({})'
264 bundle_names = ', '.join(sorted(name_services_map.keys()))
265 raise ValueError(
266 msg.format(bundle_name, bundle_names).encode('utf-8'))
267 if not bundle_services:
268 msg = 'bundle {} does not include any services'.format(bundle_name)
269 raise ValueError(msg.encode('utf-8'))
270 if settings.JUJU_GUI_SERVICE_NAME in bundle_services:
271 msg = ('bundle {} contains an instance of juju-gui. quickstart will '
272 'install the latest version of the Juju GUI automatically, '
273 'please remove juju-gui from the bundle.'.format(bundle_name))
274 raise ValueError(msg.encode('utf-8'))
275 return bundle_name, bundle_services
276
277
278def parse_status_output(output, keys=None):
279 """Parse the output of juju status.
280
281 Return selection specified by the keys array.
282 Raise a ValueError if the selection cannot be retrieved.
283 """
284 if keys is None:
285 keys = ['dummy']
286 try:
287 status = serializers.yaml_load(output)
288 except Exception as err:
289 raise ValueError(b'unable to parse the output: {}'.format(err))
290
291 selection = status
292 for key in keys:
293 try:
294 selection = selection.get(key, {})
295 except AttributeError as err:
296 msg = 'invalid YAML contents: {}'.format(status)
297 raise ValueError(msg.encode('utf-8'))
298 if selection == {}:
299 msg = '{} not found in {}'.format(':'.join(keys), status)
300 raise ValueError(msg.encode('utf-8'))
301 return selection
302
303
304def get_agent_state(output):
305 """Parse the output of juju status for the agent state.
306
307 Return the agent state.
308 Raise a ValueError if the agent state cannot be retrieved.
309 """
310 return parse_status_output(output, ['machines', '0', 'agent-state'])
311
312
313def get_bootstrap_node_series(output):
314 """Parse the output of juju status for the agent state.
315
316 Return the agent state.
317 Raise a ValueError if the agent state cannot be retrieved.
318 """
319 return parse_status_output(output, ['machines', '0', 'series'])
320
321
322def get_juju_version(juju_command):127def get_juju_version(juju_command):
323 """Return the current juju-core version.128 """Return the current juju-core version.
324129

Subscribers

People subscribed via source and target branches