Merge lp:~frankban/juju-quickstart/quickstart-bundle-file into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 7
Proposed branch: lp:~frankban/juju-quickstart/quickstart-bundle-file
Merge into: lp:juju-quickstart
Diff against target: 602 lines (+427/-7)
10 files modified
quickstart/__init__.py (+1/-1)
quickstart/app.py (+17/-0)
quickstart/juju.py (+12/-0)
quickstart/manage.py (+62/-4)
quickstart/tests/helpers.py (+26/-0)
quickstart/tests/test_app.py (+37/-0)
quickstart/tests/test_juju.py (+22/-0)
quickstart/tests/test_manage.py (+108/-2)
quickstart/tests/test_utils.py (+88/-0)
quickstart/utils.py (+54/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/quickstart-bundle-file
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+192194@code.launchpad.net

Description of the change

Deploy a bundle from a file path.

Tests: run `make check`.
QA (assuming you have an ec2 environment set up named 'ec2'):
- run `.venv/bin/python juju-quickstart /no/such/file`:
  you should see the following error:
  "unable to open bundle file: [Errno 2]
  No such file or directory: '/no/such/file'";
- run `echo "env: no-services" > ~/invalid-bundle.yaml`;
- run `.venv/bin/python juju-quickstart ~/invalid-bundle.yaml`:
  you should see the following error:
  "invalid YAML contents: {'env': 'no-services'}";
- remove the file you created in your home:
  `rm ~/invalid-bundle.yaml`;
- download the bundle file from
  http://pastebin.ubuntu.com/6283832/ and save it
  for later as '../mybundle.yaml';
- run `.venv/bin/python juju-quickstart ../mybundle.yaml`:
  you should see the following error:
  "multiple bundles found (exp1, exp2) but no bundle name specified";
- run `.venv/bin/python juju-quickstart ../mybundle.yaml -n exp1`:
  this time the quickstart application should start normally.
- wait fr quickstart to complete: after some minutes the browser
  should open on the GUI page, the bundle deployment should be
  started, and the app exits without erros.

Remember to destroy your ec2 environment.

https://codereview.appspot.com/15880043/

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

Reviewers: mp+192194_code.launchpad.net,

Message:
Please take a look.

Description:
Deploy a bundle from a file path.

Tests: run `make check`.
QA (assuming you have an ec2 environment set up named 'ec2'):
- run `.venv/bin/python juju-quickstart /no/such/file`:
   you should see the following error:
   "unable to open bundle file: [Errno 2]
   No such file or directory: '/no/such/file'";
- run `echo "env: no-services" > ~/invalid-bundle.yaml`;
- run `.venv/bin/python juju-quickstart ~/invalid-bundle.yaml`:
   you should see the following error:
   "invalid YAML contents: {'env': 'no-services'}";
- remove the file you created in your home:
   `rm ~/invalid-bundle.yaml`;
- download the bundle file from
   http://pastebin.ubuntu.com/6283832/ and save it
   for later as '../mybundle.yaml';
- run `.venv/bin/python juju-quickstart ../mybundle.yaml`:
   you should see the following error:
   "multiple bundles found (exp1, exp2) but no bundle name specified";
- run `.venv/bin/python juju-quickstart ../mybundle.yaml -n exp1`:
   this time the quickstart application should start normally.
- wait fr quickstart to complete: after some minutes the browser
   should open on the GUI page, the bundle deployment should be
   started, and the app exits without erros.

Remember to destroy your ec2 environment.

https://code.launchpad.net/~frankban/juju-quickstart/quickstart-bundle-file/+merge/192194

(do not edit description out of merge proposal)

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

Affected files (+423, -1 lines):
   A [revision details]
   M quickstart/__init__.py
   M quickstart/app.py
   M quickstart/juju.py
   M quickstart/manage.py
   M quickstart/tests/helpers.py
   M quickstart/tests/test_app.py
   M quickstart/tests/test_juju.py
   M quickstart/tests/test_manage.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

Revision history for this message
Gary Poster (gary) wrote :

LGTM! Thank you. :-)

I will qa later if no one else has been able to.

Gary

https://codereview.appspot.com/15880043/diff/1/quickstart/manage.py
File quickstart/manage.py (right):

https://codereview.appspot.com/15880043/diff/1/quickstart/manage.py#newcode156
quickstart/manage.py:156: 'option is ignored if the bundle file is not
specified')
I'd be tempted to change all the help to full sentences, with capital
letters and such, and then replace a lot of the semicolons with periods.

https://codereview.appspot.com/15880043/diff/1/quickstart/utils.py
File quickstart/utils.py (right):

https://codereview.appspot.com/15880043/diff/1/quickstart/utils.py#newcode130
quickstart/utils.py:130: msg = 'bundle {} does not include any
service'.format(bundle_name)
any services

https://codereview.appspot.com/15880043/

29. By Francesco Banconi

Changes as per review.

30. By Francesco Banconi

Fix tests.

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

*** Submitted:

Deploy a bundle from a file path.

Tests: run `make check`.
QA (assuming you have an ec2 environment set up named 'ec2'):
- run `.venv/bin/python juju-quickstart /no/such/file`:
   you should see the following error:
   "unable to open bundle file: [Errno 2]
   No such file or directory: '/no/such/file'";
- run `echo "env: no-services" > ~/invalid-bundle.yaml`;
- run `.venv/bin/python juju-quickstart ~/invalid-bundle.yaml`:
   you should see the following error:
   "invalid YAML contents: {'env': 'no-services'}";
- remove the file you created in your home:
   `rm ~/invalid-bundle.yaml`;
- download the bundle file from
   http://pastebin.ubuntu.com/6283832/ and save it
   for later as '../mybundle.yaml';
- run `.venv/bin/python juju-quickstart ../mybundle.yaml`:
   you should see the following error:
   "multiple bundles found (exp1, exp2) but no bundle name specified";
- run `.venv/bin/python juju-quickstart ../mybundle.yaml -n exp1`:
   this time the quickstart application should start normally.
- wait fr quickstart to complete: after some minutes the browser
   should open on the GUI page, the bundle deployment should be
   started, and the app exits without erros.

Remember to destroy your ec2 environment.

R=gary.poster
CC=
https://codereview.appspot.com/15880043

https://codereview.appspot.com/15880043/diff/1/quickstart/manage.py
File quickstart/manage.py (right):

https://codereview.appspot.com/15880043/diff/1/quickstart/manage.py#newcode156
quickstart/manage.py:156: 'option is ignored if the bundle file is not
specified')
On 2013/10/22 21:00:51, gary.poster wrote:
> I'd be tempted to change all the help to full sentences, with capital
letters
> and such, and then replace a lot of the semicolons with periods.

Done.

https://codereview.appspot.com/15880043/diff/1/quickstart/utils.py
File quickstart/utils.py (right):

https://codereview.appspot.com/15880043/diff/1/quickstart/utils.py#newcode130
quickstart/utils.py:130: msg = 'bundle {} does not include any
service'.format(bundle_name)
On 2013/10/22 21:00:51, gary.poster wrote:
> any services

Done.

https://codereview.appspot.com/15880043/

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'quickstart/__init__.py'
2--- quickstart/__init__.py 2013-10-18 08:05:43 +0000
3+++ quickstart/__init__.py 2013-10-22 22:03:55 +0000
4@@ -19,7 +19,7 @@
5 that it can be managed using a Web interface (the Juju GUI).
6 """
7
8-VERSION = (0, 1, 0)
9+VERSION = (0, 2, 0)
10
11
12 def get_version():
13
14=== modified file 'quickstart/app.py'
15--- quickstart/app.py 2013-10-18 14:06:25 +0000
16+++ quickstart/app.py 2013-10-22 22:03:55 +0000
17@@ -174,3 +174,20 @@
18 print('{} is ready'.format(unit_name))
19 return address
20 current_status = status
21+
22+
23+def deploy_bundle(api_url, admin_secret, bundle_yaml, bundle_name):
24+ """Deploy a bundle.
25+
26+ Receive an API URL to a WebSocket server supporting bundle deployments, the
27+ admin_secret to use in the authentication process, the bundle YAML encoded
28+ contents and the bundle name to be imported.
29+
30+ Raise a ProgramExit if the API server returns an error response.
31+ """
32+ env = connect(api_url, admin_secret)
33+ try:
34+ env.deploy_bundle(bundle_yaml, name=bundle_name)
35+ env.close()
36+ except jujuclient.EnvError as err:
37+ raise ProgramExit('bad API server response: {}'.format(err.message))
38
39=== modified file 'quickstart/juju.py'
40--- quickstart/juju.py 2013-10-17 19:20:55 +0000
41+++ quickstart/juju.py 2013-10-22 22:03:55 +0000
42@@ -70,6 +70,18 @@
43 }
44 return self._rpc(request)
45
46+ def deploy_bundle(self, yaml, name=None):
47+ """Deploy a bundle."""
48+ params = {'YAML': yaml}
49+ if name is not None:
50+ params['Name'] = name
51+ request = {
52+ 'Type': 'Deployer',
53+ 'Request': 'Import',
54+ 'Params': params,
55+ }
56+ return self._rpc(request)
57+
58 def watch_changes(self, processor):
59 """Start watching the changes occurring in the Juju environment.
60
61
62=== modified file 'quickstart/manage.py'
63--- quickstart/manage.py 2013-10-18 14:15:49 +0000
64+++ quickstart/manage.py 2013-10-22 22:03:55 +0000
65@@ -42,6 +42,38 @@
66 parser.exit()
67
68
69+def _validate_bundle(options, parser):
70+ """Validate and process the bundle options.
71+
72+ Populate the options namespace with the following names:
73+ - bundle_file: the full path to the bundle file;
74+ - bundle_name: the name of the bundle;
75+ - bundle_services: a list of service names included in the bundle;
76+ - bundle_yaml: the YAML encoded contents of the bundle.
77+
78+ Exit with an error if the bundle options are not valid.
79+ """
80+ # XXX 2013-10-18 frankban:
81+ # This function is supposed to also support bundle URLs.
82+ bundle_file = os.path.abspath(os.path.expanduser(options.bundle))
83+ # Load the bundle file.
84+ try:
85+ bundle_yaml = open(bundle_file).read()
86+ except IOError as err:
87+ return parser.error('unable to open bundle file: {}'.format(err))
88+ # Validate the bundle.
89+ try:
90+ bundle_name, bundle_services = utils.parse_bundle(
91+ bundle_yaml, options.bundle_name)
92+ except ValueError as err:
93+ return parser.error(str(err))
94+ # Update the options namespace with the new values.
95+ options.bundle_file = bundle_file
96+ options.bundle_name = bundle_name
97+ options.bundle_services = bundle_services
98+ options.bundle_yaml = bundle_yaml
99+
100+
101 def _validate_env(options, parser):
102 """Validate and process the provided environment related options.
103
104@@ -103,30 +135,43 @@
105 """
106 default_env_name = utils.get_default_env_name()
107 # Define the help message for the --environment option.
108- env_help = 'the name of the Juju environment to use'
109+ env_help = 'The name of the Juju environment to use'
110 if default_env_name is not None:
111 env_help = '{} (%(default)s)'.format(env_help)
112 # Create and set up the arguments parser.
113 parser = argparse.ArgumentParser(description=quickstart.__doc__)
114+ # XXX 2013-10-18 frankban:
115+ # Make it possible to pass a URL as bundle argument.
116+ parser.add_argument(
117+ 'bundle', default=None, nargs='?',
118+ help='The path to the bundle file to deploy')
119 parser.add_argument(
120 '-e', '--environment', default=default_env_name, dest='env_name',
121 help=env_help)
122 parser.add_argument(
123+ '-n', '--bundle-name', default=None, dest='bundle_name',
124+ help='The name of the bundle to use. This must be included in the '
125+ 'provided bundle file. Specifying the bundle name is not '
126+ 'required if the bundle file only contains one bundle. This '
127+ 'option is ignored if the bundle file is not specified')
128+ parser.add_argument(
129 '--environments-file',
130 default=os.path.join(juju_home, 'environments.yaml'), dest='env_file',
131- help='the path to the Juju environments YAML file (%(default)s)')
132+ help='The path to the Juju environments YAML file (%(default)s)')
133 parser.add_argument(
134 '--version', action='version', version='%(prog)s {}'.format(version))
135 parser.add_argument(
136- '--debug', action='store_true', help='turn debug mode on')
137+ '--debug', action='store_true', help='Turn debug mode on')
138 # This is required by juju-core: see "juju help plugins".
139 parser.add_argument(
140 '--description', action=_DescriptionAction, default=argparse.SUPPRESS,
141- nargs=0, help="show program's description and exit")
142+ nargs=0, help="Show program's description and exit")
143 # Parse the provided arguments.
144 options = parser.parse_args()
145 # Validate and process the provided arguments.
146 _validate_env(options, parser)
147+ if options.bundle is not None:
148+ _validate_bundle(options, parser)
149 # Set up logging.
150 _configure_logging(logging.DEBUG if options.debug else logging.INFO)
151 return options
152@@ -148,6 +193,19 @@
153 address = app.watch(env, 'juju-gui')
154 url = 'https://{}'.format(address)
155 print('url: {}\npassword: {}'.format(url, options.admin_secret))
156+
157+ # Handle bundle deployment.
158+ if options.bundle is not None:
159+ print('deploying the bundle {} with the following services: {}'.format(
160+ options.bundle_name, ', '.join(options.bundle_services)))
161+ # We need to connect to an API WebSocket server supporting bundle
162+ # deployments. The GUI builtin server, listening on the Juju GUI
163+ # address, exposes an API suitable for deploying bundles.
164+ gui_api_url = 'wss://{}:443/ws'.format(address)
165+ app.deploy_bundle(
166+ gui_api_url, options.admin_secret,
167+ options.bundle_yaml, options.bundle_name)
168+
169 # XXX 2013-10-18 frankban:
170 # Add a command line option to decline opening the browser.
171 webbrowser.open(url)
172
173=== modified file 'quickstart/tests/helpers.py'
174--- quickstart/tests/helpers.py 2013-10-17 16:51:55 +0000
175+++ quickstart/tests/helpers.py 2013-10-22 22:03:55 +0000
176@@ -51,6 +51,32 @@
177 return mock.patch('quickstart.utils.call', mock_call)
178
179
180+class BundleFileTestsMixin(object):
181+ """Shared methods for testing Juju bundle files."""
182+
183+ valid_bundle = yaml.safe_dump({
184+ 'bundle1': {'services': {'wordpress': {}, 'mysql': {}}},
185+ 'bundle2': {'services': {'django': {}, 'nodejs': {}}},
186+ })
187+
188+ def make_bundle_file(self, contents=None):
189+ """Create a Juju bundle file containing the given contents.
190+
191+ If contents is None, use the valid bundle contents defined in
192+ self.valid_bundle.
193+ Return the bundle file path.
194+ """
195+ if contents is None:
196+ contents = self.valid_bundle
197+ elif isinstance(contents, dict):
198+ contents = yaml.safe_dump(contents)
199+ bundle_file = tempfile.NamedTemporaryFile(delete=False)
200+ self.addCleanup(os.remove, bundle_file.name)
201+ bundle_file.write(contents)
202+ bundle_file.close()
203+ return bundle_file.name
204+
205+
206 class EnvFileTestsMixin(object):
207 """Shared methods for testing a Juju environments file."""
208
209
210=== modified file 'quickstart/tests/test_app.py'
211--- quickstart/tests/test_app.py 2013-10-18 14:07:28 +0000
212+++ quickstart/tests/test_app.py 2013-10-22 22:03:55 +0000
213@@ -431,3 +431,40 @@
214 with self.assert_program_exit(expected):
215 app.watch(env, 'django')
216 mock_print.assert_has_calls([self.pending_call])
217+
218+
219+@mock.patch('quickstart.app.connect')
220+class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase):
221+
222+ admin_secret = 'Secret!'
223+ api_url = 'wss://juju-gui.example.com:443/ws'
224+ name = 'mybundle'
225+ yaml = 'mybundle: contents'
226+
227+ def test_bundle_deployment(self, mock_connect):
228+ # A bundle is successfully deployed.
229+ app.deploy_bundle(
230+ self.api_url, self.admin_secret, self.yaml, self.name)
231+ mock_connect.assert_called_once_with(self.api_url, self.admin_secret)
232+ mock_connect().assert_has_calls([
233+ mock.call.deploy_bundle(self.yaml, name=self.name),
234+ mock.call.close(),
235+ ])
236+
237+ def test_api_error(self, mock_connect):
238+ # A ProgramExit is raised if an error occurs in one of the API calls.
239+ mock_connect().deploy_bundle.side_effect = self.make_env_error(
240+ 'bundle deployment failure')
241+ expected = 'bad API server response: bundle deployment failure'
242+ with self.assert_program_exit(expected):
243+ app.deploy_bundle(
244+ self.api_url, self.admin_secret, self.yaml, self.name)
245+
246+ def test_other_errors(self, mock_connect):
247+ # Any other errors occurred during the process are not trapped.
248+ error = ValueError('explode!')
249+ mock_connect().deploy_bundle.side_effect = error
250+ with self.assertRaises(ValueError) as context_manager:
251+ app.deploy_bundle(
252+ self.api_url, self.admin_secret, self.yaml, self.name)
253+ self.assertIs(error, context_manager.exception)
254
255=== modified file 'quickstart/tests/test_juju.py'
256--- quickstart/tests/test_juju.py 2013-10-17 15:05:11 +0000
257+++ quickstart/tests/test_juju.py 2013-10-22 22:03:55 +0000
258@@ -115,6 +115,28 @@
259 expected = self.make_deploy_request(ToMachineSpec='0')
260 mock_rpc.assert_called_once_with(expected)
261
262+ @mock.patch('quickstart.juju.Environment._rpc')
263+ def test_deploy_bundle(self, mock_rpc):
264+ # The deploy bundle call is properly generated.
265+ self.env.deploy_bundle('name: contents')
266+ expected = {
267+ 'Type': 'Deployer',
268+ 'Request': 'Import',
269+ 'Params': {'YAML': 'name: contents'},
270+ }
271+ mock_rpc.assert_called_once_with(expected)
272+
273+ @mock.patch('quickstart.juju.Environment._rpc')
274+ def test_deploy_bundle_with_name(self, mock_rpc):
275+ # The deploy bundle call is properly generated when passing a name.
276+ self.env.deploy_bundle('name: contents', name='name')
277+ expected = {
278+ 'Type': 'Deployer',
279+ 'Request': 'Import',
280+ 'Params': {'Name': 'name', 'YAML': 'name: contents'},
281+ }
282+ mock_rpc.assert_called_once_with(expected)
283+
284 @mock.patch('quickstart.juju.jujuclient.Watcher._rpc')
285 def test_watch_changes(self, mock_rpc):
286 # It is possible to watch for changes using a processor callable.
287
288=== modified file 'quickstart/tests/test_manage.py'
289--- quickstart/tests/test_manage.py 2013-10-16 15:23:08 +0000
290+++ quickstart/tests/test_manage.py 2013-10-22 22:03:55 +0000
291@@ -46,6 +46,60 @@
292 mock_exit.assert_called_once_with(0)
293
294
295+class TestValidateBundle(helpers.BundleFileTestsMixin, unittest.TestCase):
296+
297+ def setUp(self):
298+ self.parser = mock.Mock()
299+
300+ def make_options(self, bundle, bundle_name=None):
301+ """Return a mock options object which includes the passed arguments."""
302+ return mock.Mock(bundle=bundle, bundle_name=bundle_name)
303+
304+ def test_resulting_options(self):
305+ # The options object is correctly set up.
306+ bundle_file = self.make_bundle_file()
307+ options = self.make_options(bundle_file, bundle_name='bundle1')
308+ manage._validate_bundle(options, self.parser)
309+ self.assertEqual(bundle_file, options.bundle_file)
310+ self.assertEqual('bundle1', options.bundle_name)
311+ self.assertEqual(
312+ ['mysql', 'wordpress'], sorted(options.bundle_services))
313+ self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
314+
315+ def test_expand_user(self):
316+ # The ~ construct is correctly expanded in the validation process.
317+ bundle_file = self.make_bundle_file()
318+ # Split the full path of the bundle file, e.g. from a full
319+ # "/tmp/bundle.file" path retrieve the base path "/tmp" and the file
320+ # name "bundle.file". By doing that we can simulate that the user's
321+ # home is "/tmp" and that the bundle file is "~/bundle.file".
322+ base_path, filename = os.path.split(bundle_file)
323+ path = '~/{}'.format(filename)
324+ options = self.make_options(bundle=path, bundle_name='bundle2')
325+ with mock.patch('os.environ', {'HOME': base_path}):
326+ manage._validate_bundle(options, self.parser)
327+ self.assertEqual(bundle_file, options.bundle_file)
328+
329+ def test_bundle_file_not_found(self):
330+ # A parser error is invoked if the bundle file is not found.
331+ options = self.make_options('/no/such/file.yaml')
332+ manage._validate_bundle(options, self.parser)
333+ expected = (
334+ 'unable to open bundle file: '
335+ "[Errno 2] No such file or directory: '/no/such/file.yaml'"
336+ )
337+ self.parser.error.assert_called_once_with(expected)
338+
339+ def test_error_parsing_bundle_file(self):
340+ # A parser error is invoked if an error occurs parsing the bundle file.
341+ bundle_file = self.make_bundle_file()
342+ options = self.make_options(bundle_file, bundle_name='no-such')
343+ manage._validate_bundle(options, self.parser)
344+ expected = ('bundle no-such not found in the provided list of bundles '
345+ '(bundle1, bundle2)')
346+ self.parser.error.assert_called_once_with(expected)
347+
348+
349 class TestValidateEnv(helpers.EnvFileTestsMixin, unittest.TestCase):
350
351 def setUp(self):
352@@ -147,7 +201,7 @@
353 self.assertIn(quickstart.__doc__, output)
354 self.assertIn('--environment', output)
355 # Without a default environment, the -e option has no default.
356- self.assertIn('the name of the Juju environment to use\n', output)
357+ self.assertIn('The name of the Juju environment to use\n', output)
358
359 def test_help_with_default_environment(self):
360 # The program help message is properly formatted when a default Juju
361@@ -158,7 +212,7 @@
362 self.assertTrue(stdout_write.called)
363 # Retrieve the output from the mock call.
364 output = stdout_write.call_args[0][0]
365- self.assertIn('the name of the Juju environment to use (hp)\n', output)
366+ self.assertIn('The name of the Juju environment to use (hp)\n', output)
367
368 def test_description(self):
369 # The program description is properly printed out as required by juju.
370@@ -173,6 +227,15 @@
371 expected = 'juju-quickstart {}\n'.format(quickstart.get_version())
372 mock_stderr.write.assert_called_once_with(expected)
373
374+ @mock.patch('quickstart.manage._validate_bundle')
375+ def test_bundle(self, mock_validate_bundle):
376+ # The bundle validation process is started if a bundle is provided.
377+ self.call_setup(['/path/to/bundle.file'], exit_called=False)
378+ self.assertTrue(mock_validate_bundle.called)
379+ options, parser = mock_validate_bundle.call_args_list[0][0]
380+ self.assertIsInstance(options, argparse.Namespace)
381+ self.assertIsInstance(parser, argparse.ArgumentParser)
382+
383 def test_configure_logging(self):
384 # Logging is properly set up at the info level.
385 logger = logging.getLogger()
386@@ -184,3 +247,46 @@
387 logger = logging.getLogger()
388 self.call_setup(['--debug'], 'ec2', exit_called=False)
389 self.assertEqual(logging.DEBUG, logger.level)
390+
391+
392+@mock.patch('webbrowser.open')
393+@mock.patch('quickstart.manage.app')
394+@mock.patch('__builtin__.print', mock.Mock())
395+class TestRun(unittest.TestCase):
396+
397+ def make_options(self, **kwargs):
398+ """Set up the options to be passed to the run function."""
399+ options = {
400+ 'admin_secret': 'Secret!',
401+ 'bundle': None,
402+ 'env_name': 'aws',
403+ 'env_type': 'ec2',
404+ }
405+ options.update(kwargs)
406+ return mock.Mock(**options)
407+
408+ def test_no_bundle(self, mock_app, mock_open):
409+ # The application runs correctly if no bundle is provided.
410+ options = self.make_options()
411+ manage.run(options)
412+ mock_app.bootstrap.assert_called_once_with(options.env_name)
413+ mock_app.get_api_url.assert_called_once_with(options.env_name)
414+ mock_app.connect.assert_called_once_with(
415+ mock_app.get_api_url(), options.admin_secret)
416+ mock_app.deploy_gui.assert_called_once_with(
417+ mock_app.connect(), 'juju-gui')
418+ mock_app.watch.assert_called_once_with(mock_app.connect(), 'juju-gui')
419+ mock_open.assert_called_once_with(
420+ 'https://{}'.format(mock_app.watch()))
421+ self.assertFalse(mock_app.deploy_bundle.called)
422+
423+ def test_bundle(self, mock_app, mock_open):
424+ # A bundle is correctly deployed by the application.
425+ options = self.make_options(
426+ bundle='/my/bunlde/file.yaml', bundle_yaml='mybundle: contents',
427+ bundle_name='mybundle', bundle_services=['service1', 'service2'])
428+ mock_app.watch.return_value = 'gui.example.com'
429+ manage.run(options)
430+ mock_app.deploy_bundle.assert_called_once_with(
431+ 'wss://gui.example.com:443/ws', options.admin_secret,
432+ 'mybundle: contents', 'mybundle')
433
434=== modified file 'quickstart/tests/test_utils.py'
435--- quickstart/tests/test_utils.py 2013-10-17 19:56:17 +0000
436+++ quickstart/tests/test_utils.py 2013-10-22 22:03:55 +0000
437@@ -120,6 +120,94 @@
438 mock_call.assert_called_once_with('juju', 'switch')
439
440
441+class TestParseBundle(
442+ helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
443+ unittest.TestCase):
444+
445+ def test_invalid_yaml(self):
446+ # A ValueError is raised if the bundle contents are not a valid YAML.
447+ with self.assertRaises(ValueError) as context_manager:
448+ utils.parse_bundle(':')
449+ expected = 'unable to parse the bundle'
450+ self.assertIn(expected, str(context_manager.exception))
451+
452+ def test_yaml_invalid_type(self):
453+ # A ValueError is raised if the bundle contents are not well formed.
454+ with self.assert_value_error("invalid YAML contents: 'a-string'"):
455+ utils.parse_bundle('a-string')
456+
457+ def test_yaml_invalid_bundle_data(self):
458+ # A ValueError is raised if bundles are not well formed.
459+ contents = yaml.safe_dump({'mybundle': 'not valid'})
460+ expected = "invalid YAML contents: {'mybundle': 'not valid'}"
461+ with self.assert_value_error(expected):
462+ utils.parse_bundle(contents)
463+
464+ def test_yaml_no_service(self):
465+ # A ValueError is raised if bundles do not include services.
466+ contents = yaml.safe_dump({'mybundle': {}})
467+ expected = "invalid YAML contents: {'mybundle': {}}"
468+ with self.assert_value_error(expected):
469+ utils.parse_bundle(contents)
470+
471+ def test_yaml_none_bundle_services(self):
472+ # A ValueError is raised if services are None.
473+ contents = yaml.safe_dump({'mybundle': {'services': None}})
474+ expected = "invalid YAML contents: {'mybundle': {'services': None}}"
475+ with self.assert_value_error(expected):
476+ utils.parse_bundle(contents)
477+
478+ def test_yaml_invalid_bundle_services_type(self):
479+ # A ValueError is raised if services have an invalid type.
480+ contents = yaml.safe_dump({'mybundle': {'services': 42}})
481+ expected = "invalid YAML contents: {'mybundle': {'services': 42}}"
482+ with self.assert_value_error(expected):
483+ utils.parse_bundle(contents)
484+
485+ def test_yaml_no_bundles(self):
486+ # A ValueError is raised if the bundle contents are empty.
487+ with self.assert_value_error('no bundles found'):
488+ utils.parse_bundle(yaml.safe_dump({}))
489+
490+ def test_bundle_name_not_specified(self):
491+ # A ValueError is raised if the bundle name is not specified and the
492+ # contents contain more than one bundle.
493+ expected = ('multiple bundles found (bundle1, bundle2) '
494+ 'but no bundle name specified')
495+ with self.assert_value_error(expected):
496+ utils.parse_bundle(self.valid_bundle)
497+
498+ def test_bundle_name_not_found(self):
499+ # A ValueError is raised if the given bundle is not found in the file.
500+ expected = ('bundle no-such not found in the provided list of bundles '
501+ '(bundle1, bundle2)')
502+ with self.assert_value_error(expected):
503+ utils.parse_bundle(self.valid_bundle, 'no-such')
504+
505+ def test_no_services(self):
506+ # A ValueError is raised if the specified bundle does not contain
507+ # services.
508+ contents = yaml.safe_dump({'mybundle': {'services': {}}})
509+ expected = 'bundle mybundle does not include any services'
510+ with self.assert_value_error(expected):
511+ utils.parse_bundle(contents)
512+
513+ def test_success_no_name(self):
514+ # The function succeeds when an implicit bundle name is used.
515+ contents = yaml.safe_dump({
516+ 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
517+ })
518+ name, services = utils.parse_bundle(contents)
519+ self.assertEqual('mybundle', name)
520+ self.assertEqual(['mysql', 'wordpress'], sorted(services))
521+
522+ def test_success_multiple_bundles(self):
523+ # The function succeeds with multiple bundles.
524+ name, services = utils.parse_bundle(self.valid_bundle, 'bundle2')
525+ self.assertEqual('bundle2', name)
526+ self.assertEqual(['django', 'nodejs'], sorted(services))
527+
528+
529 class TestParseEnvFile(
530 helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin,
531 unittest.TestCase):
532
533=== modified file 'quickstart/utils.py'
534--- quickstart/utils.py 2013-10-18 10:10:30 +0000
535+++ quickstart/utils.py 2013-10-22 22:03:55 +0000
536@@ -16,6 +16,7 @@
537
538 """Juju Quickstart utility functions and classes."""
539
540+import collections
541 import re
542 import os
543 import logging
544@@ -80,6 +81,59 @@
545 return match.groups()[0]
546
547
548+def parse_bundle(bundle_yaml, bundle_name=None):
549+ """Parse the provided bundle YAML decoded contents.
550+
551+ Return a tuple containing the bundle name and the list of services included
552+ in the bundle.
553+
554+ Raise a ValueError if:
555+ - the bundle YAML contents are not parsable by YAML;
556+ - the YAML contents are not properly structured;
557+ - the bundle name is specified but not included in the bundle file;
558+ - the bundle name is not specified and the bundle file includes more than
559+ one bundle;
560+ - the bundle does not include services.
561+ """
562+ # Parse the bundle file.
563+ try:
564+ bundles = yaml.safe_load(bundle_yaml)
565+ except Exception as err:
566+ msg = 'unable to parse the bundle: {}'.format(err)
567+ raise ValueError(msg)
568+ # Ensure the bundle file is well formed and contains at least one bundle.
569+ if not isinstance(bundles, collections.Mapping):
570+ raise ValueError('invalid YAML contents: {!r}'.format(bundles))
571+ try:
572+ name_services_map = dict(
573+ (key, value['services'].keys())
574+ for key, value in bundles.items()
575+ )
576+ except (AttributeError, KeyError, TypeError):
577+ raise ValueError('invalid YAML contents: {!r}'.format(bundles))
578+ if not name_services_map:
579+ raise ValueError('no bundles found')
580+ # Retrieve the bundle name and services.
581+ if bundle_name is None:
582+ if len(name_services_map) > 1:
583+ msg = 'multiple bundles found ({}) but no bundle name specified'
584+ bundle_names = ', '.join(sorted(name_services_map.keys()))
585+ raise ValueError(msg.format(bundle_names))
586+ bundle_name, bundle_services = name_services_map.items()[0]
587+ else:
588+ bundle_services = name_services_map.get(bundle_name)
589+ if bundle_services is None:
590+ msg = 'bundle {} not found in the provided list of bundles ({})'
591+ bundle_names = ', '.join(sorted(name_services_map.keys()))
592+ raise ValueError(msg.format(bundle_name, bundle_names))
593+ if not bundle_services:
594+ msg = 'bundle {} does not include any services'.format(bundle_name)
595+ raise ValueError(msg)
596+ # XXX 2013-10-21 frankban:
597+ # Handle the case when the GUI is included in the bundle.
598+ return bundle_name, bundle_services
599+
600+
601 def parse_env_file(env_file, env_name):
602 """Parse the provided Juju environments.yaml file.
603

Subscribers

People subscribed via source and target branches