Merge lp:~frankban/juju-gui/quickstart-real-bootstrap into lp:juju-gui/juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 4
Proposed branch: lp:~frankban/juju-gui/quickstart-real-bootstrap
Merge into: lp:juju-gui/juju-quickstart
Diff against target: 584 lines (+365/-33)
7 files modified
quickstart/app.py (+39/-5)
quickstart/manage.py (+28/-2)
quickstart/tests/helpers.py (+40/-12)
quickstart/tests/test_app.py (+155/-0)
quickstart/tests/test_manage.py (+17/-3)
quickstart/tests/test_utils.py (+47/-7)
quickstart/utils.py (+39/-4)
To merge this branch: bzr merge lp:~frankban/juju-gui/quickstart-real-bootstrap
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+191410@code.launchpad.net

Description of the change

Bootstrap the Juju environment.

Wait until the API server is ready.
Retrieve the API address.

Also added --debug support (logging).

Tests: `make check`.

QA: assuming you have a local provider env
named 'local' and an ec2 one named 'ec2'

- run `.venv/bin/python juju-quickstart -e local`
  -> error: the local provider is not currently supported;
- run `.venv/bin/python juju-quickstart -e no-such-env`
  -> error: environment no-such-env not found in ...;
- run `juju switch ec2`;
- run `.venv/bin/python juju-quickstart --debug`
  -> the debug messages are shown, the env is bootstrapped,
     the API URL is printed at the end of the process.
- run `.venv/bin/python juju-quickstart`
  -? like above but this time no debug messages are shown.

https://codereview.appspot.com/14441074/

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

Reviewers: mp+191410_code.launchpad.net,

Message:
Please take a look.

Description:
Bootstrap the Juju environment.

Wait until the API server is ready.
Retrieve the API address.

Also added --debug support (logging).

Tests: `make check`.

QA: assuming you have a local provider env
named 'local' and an ec2 one named 'ec2'

- run `.venv/bin/python juju-quickstart -e local`
   -> error: the local provider is not currently supported;
- run `.venv/bin/python juju-quickstart -e no-such-env`
   -> error: environment no-such-env not found in ...;
- run `juju switch ec2`;
- run `.venv/bin/python juju-quickstart --debug`
   -> the debug messages are shown, the env is bootstrapped,
      the API URL is printed at the end of the process.
- run `.venv/bin/python juju-quickstart`
   -? like above but this time no debug messages are shown.

https://code.launchpad.net/~frankban/juju-gui/quickstart-real-bootstrap/+merge/191410

(do not edit description out of merge proposal)

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

Affected files (+371, -33 lines):
   A [revision details]
   M quickstart/app.py
   M quickstart/manage.py
   M quickstart/tests/helpers.py
   M quickstart/tests/test_app.py
   M quickstart/tests/test_manage.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

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

LGTM Francesco. I have not yet done QA but can if needed.

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

https://codereview.appspot.com/14441074/diff/1/quickstart/manage.py#newcode78
quickstart/manage.py:78: if root.handlers:
A comment here explaining what you're doing would be welcome.

https://codereview.appspot.com/14441074/

15. By Francesco Banconi

Changes as per review.

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

Please take a look.

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

https://codereview.appspot.com/14441074/diff/1/quickstart/manage.py#newcode78
quickstart/manage.py:78: if root.handlers:
On 2013/10/16 14:51:20, bac wrote:
> A comment here explaining what you're doing would be welcome.

Done.

https://codereview.appspot.com/14441074/

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

LGTM with an extremely small suggestion that you are welcome to ignore.
It is interesting and convenient that so many of the commands have
(seemingly) reliably parsable output. The branch has nice and
interesting tests. Thank you!

https://codereview.appspot.com/14441074/diff/5001/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/14441074/diff/5001/quickstart/app.py#newcode49
quickstart/app.py:49: retcode, output, error = utils.call('juju',
'status', '-e', env_name)
I'm OK with this for expediency, but wouldn't the API (Kapil's library)
be better in a revision?

[later] Oh, it's YAML, yeah? I guess that's OK. I think it would be
nice to add a comment to that effect, but others might disagree. Just a
suggestion.

https://codereview.appspot.com/14441074/diff/5001/quickstart/app.py#newcode70
quickstart/app.py:70: 'juju', 'api-endpoints', '-e', env_name,
'--format', 'json')
yay, --format json

https://codereview.appspot.com/14441074/diff/5001/quickstart/utils.py
File quickstart/utils.py (right):

https://codereview.appspot.com/14441074/diff/5001/quickstart/utils.py#newcode69
quickstart/utils.py:69: # Switch to using "juju switch --raw" in order
to avoid the fragility
oh, cool.

https://codereview.appspot.com/14441074/

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

*** Submitted:

Bootstrap the Juju environment.

Wait until the API server is ready.
Retrieve the API address.

Also added --debug support (logging).

Tests: `make check`.

QA: assuming you have a local provider env
named 'local' and an ec2 one named 'ec2'

- run `.venv/bin/python juju-quickstart -e local`
   -> error: the local provider is not currently supported;
- run `.venv/bin/python juju-quickstart -e no-such-env`
   -> error: environment no-such-env not found in ...;
- run `juju switch ec2`;
- run `.venv/bin/python juju-quickstart --debug`
   -> the debug messages are shown, the env is bootstrapped,
      the API URL is printed at the end of the process.
- run `.venv/bin/python juju-quickstart`
   -? like above but this time no debug messages are shown.

R=bac, gary.poster
CC=
https://codereview.appspot.com/14441074

https://codereview.appspot.com/14441074/diff/5001/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/14441074/diff/5001/quickstart/app.py#newcode49
quickstart/app.py:49: retcode, output, error = utils.call('juju',
'status', '-e', env_name)
On 2013/10/16 15:43:40, gary.poster wrote:
> I'm OK with this for expediency, but wouldn't the API (Kapil's
library) be
> better in a revision?

> [later] Oh, it's YAML, yeah? I guess that's OK. I think it would be
nice to
> add a comment to that effect, but others might disagree. Just a
suggestion.

Good suggestion. As discussed, I will explicitly add "--format yaml"
here.

https://codereview.appspot.com/14441074/

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/app.py'
2--- quickstart/app.py 2013-10-15 15:31:12 +0000
3+++ quickstart/app.py 2013-10-16 15:23:20 +0000
4@@ -16,6 +16,10 @@
5
6 """Juju Quickstart base application functions."""
7
8+import json
9+
10+from quickstart import utils
11+
12
13 class ProgramExit(Exception):
14 """An error occurred while setting up the Juju environment.
15@@ -34,9 +38,39 @@
16 def bootstrap(env_name):
17 """Bootstrap the Juju environment with the given name.
18
19- Return the environment API address (e.g. "api.example.com:17070").
20+ Return when the bootstrap node is ready.
21 Raise a ProgramExit if any error occurs in the bootstrap process.
22- Otherwise return when the environment is bootstrapped and the API server
23- is ready to accept connections.
24- """
25- # TODO: everything!
26+ """
27+ retcode, _, error = utils.call('juju', 'bootstrap', '-e', env_name)
28+ if retcode:
29+ raise ProgramExit(error)
30+ # Call "juju status" multiple times until the bootstrap node is ready.
31+ for _ in range(5):
32+ retcode, output, error = utils.call('juju', 'status', '-e', env_name)
33+ if retcode:
34+ continue
35+ # Ensure the state server is up and the agent is started.
36+ try:
37+ agent_state = utils.parse_status_output(output)
38+ except ValueError:
39+ continue
40+ if agent_state == 'started':
41+ return
42+ details = ''.join(filter(None, [output, error])).strip()
43+ raise ProgramExit('the state server is not ready:\n{}'.format(details))
44+
45+
46+def get_api_url(env_name):
47+ """Return a Juju API URL for the given environment name.
48+
49+ Use the Juju CLI in a subprocess in order to retrieve the API addresses.
50+ Return the complete URL, e.g. "wss://api.example.com:17070".
51+ """
52+ retcode, output, error = utils.call(
53+ 'juju', 'api-endpoints', '-e', env_name, '--format', 'json')
54+ if retcode:
55+ raise ProgramExit(error)
56+ # Assuming there is always at least one API address, grab the first one
57+ # from the JSON output.
58+ api_address = json.loads(output)[0]
59+ return 'wss://{}'.format(api_address)
60
61=== modified file 'quickstart/manage.py'
62--- quickstart/manage.py 2013-10-15 15:40:31 +0000
63+++ quickstart/manage.py 2013-10-16 15:23:20 +0000
64@@ -18,6 +18,7 @@
65
66 from __future__ import print_function
67 import argparse
68+import logging
69 import os
70
71 import quickstart
72@@ -54,7 +55,8 @@
73 'It is possible to specify the environment name by either:\n'
74 ' - passing the -e or --environment argument;\n'
75 ' - setting the JUJU_ENV environment variable;\n'
76- ' - using "juju switch" to select the default environment to use.'
77+ ' - using "juju switch" to select the default environment;\n'
78+ ' - setting the default environment in {}.'.format(env_file)
79 )
80 # Validate the environment file.
81 try:
82@@ -70,8 +72,25 @@
83 options.env_type = env_type
84
85
86+def _configure_logging(level):
87+ """Set up the application logging."""
88+ root = logging.getLogger()
89+ # Remove any previous handler on the root logger.
90+ for handler in root.handlers[:]:
91+ root.removeHandler(handler)
92+ logging.basicConfig(
93+ level=level,
94+ format=(
95+ '%(asctime)s %(levelname)s '
96+ '%(module)s@%(funcName)s:%(lineno)d '
97+ '%(message)s'
98+ ),
99+ datefmt='%H:%M:%S',
100+ )
101+
102+
103 def setup():
104- """Set up the application options.
105+ """Set up the application options and logger.
106
107 Return the options as a namespace containing the followin attributes:
108 - admin_secret: the password to use to access the Juju API;
109@@ -97,6 +116,8 @@
110 help='the path to the Juju environments YAML file (%(default)s)')
111 parser.add_argument(
112 '--version', action='version', version='%(prog)s {}'.format(version))
113+ parser.add_argument(
114+ '--debug', action='store_true', help='turn debug mode on')
115 # This is required by juju-core: see "juju help plugins".
116 parser.add_argument(
117 '--description', action=_DescriptionAction, default=argparse.SUPPRESS,
118@@ -105,6 +126,8 @@
119 options = parser.parse_args()
120 # Validate and process the provided arguments.
121 _validate_env(options, parser)
122+ # Set up logging.
123+ _configure_logging(logging.DEBUG if options.debug else logging.INFO)
124 return options
125
126
127@@ -114,3 +137,6 @@
128 print('bootstrapping the {} environment (type: {})'.format(
129 options.env_name, options.env_type))
130 app.bootstrap(options.env_name)
131+ print('retrieving the Juju API address')
132+ api_url = app.get_api_url(options.env_name)
133+ print('connecting to {}'.format(api_url))
134
135=== modified file 'quickstart/tests/helpers.py'
136--- quickstart/tests/helpers.py 2013-10-15 18:25:57 +0000
137+++ quickstart/tests/helpers.py 2013-10-16 15:23:20 +0000
138@@ -20,21 +20,35 @@
139 import os
140 import tempfile
141
142+import mock
143 import yaml
144
145
146-class ValueErrorTestsMixin(object):
147- """Set up some base methods for testing functions raising ValueErrors."""
148-
149- @contextmanager
150- def assert_value_error(self, error):
151- """Ensure a ValueError is raised in the context block.
152-
153- Also check that the exception includes the expected error message.
154- """
155- with self.assertRaises(ValueError) as context_manager:
156- yield
157- self.assertEqual(error, str(context_manager.exception))
158+class CallTestsMixin(object):
159+ """Easily use the quickstart.utils.call function."""
160+
161+ def patch_call(self, retcode, output='', error=''):
162+ """Patch the quickstart.utils.call function."""
163+ mock_call = mock.Mock(return_value=(retcode, output, error))
164+ return mock.patch('quickstart.utils.call', mock_call)
165+
166+ def patch_multiple_calls(self, side_effect):
167+ """Patch multiple subsequent quickstart.utils.call calls."""
168+ mock_call = mock.Mock(side_effect=side_effect)
169+ return mock.patch('quickstart.utils.call', mock_call)
170+
171+
172+@contextmanager
173+def assert_logs(messages, level='debug'):
174+ """Ensure the given messages are logged using the given log level.
175+
176+ Use this function as a context manager: the code executed in the context
177+ block must add the expected log entries.
178+ """
179+ with mock.patch('logging.{}'.format(level.lower())) as mock_log:
180+ yield
181+ expected_calls = [mock.call(message) for message in messages]
182+ mock_log.assert_has_calls(expected_calls)
183
184
185 class EnvFileTestsMixin(object):
186@@ -58,3 +72,17 @@
187 env_file.write(contents)
188 env_file.close()
189 return env_file.name
190+
191+
192+class ValueErrorTestsMixin(object):
193+ """Set up some base methods for testing functions raising ValueErrors."""
194+
195+ @contextmanager
196+ def assert_value_error(self, error):
197+ """Ensure a ValueError is raised in the context block.
198+
199+ Also check that the exception includes the expected error message.
200+ """
201+ with self.assertRaises(ValueError) as context_manager:
202+ yield
203+ self.assertEqual(error, str(context_manager.exception))
204
205=== modified file 'quickstart/tests/test_app.py'
206--- quickstart/tests/test_app.py 2013-10-15 15:31:32 +0000
207+++ quickstart/tests/test_app.py 2013-10-16 15:23:20 +0000
208@@ -16,9 +16,15 @@
209
210 """Tests for the Juju Quickstart base application functions."""
211
212+from contextlib import contextmanager
213+import json
214 import unittest
215
216+import mock
217+import yaml
218+
219 from quickstart import app
220+from quickstart.tests import helpers
221
222
223 class TestProgramExit(unittest.TestCase):
224@@ -27,3 +33,152 @@
225 # The error is properly represented as a string.
226 exception = app.ProgramExit('bad wolf')
227 self.assertEqual('juju-quickstart: error: bad wolf', str(exception))
228+
229+
230+class ProgramExitTestsMixin(object):
231+ """Set up some base methods for testing functions raising ProgramExit."""
232+
233+ @contextmanager
234+ def assert_program_exit(self, error):
235+ """Ensure a ProgramExit is raised in the context block.
236+
237+ Also check that the exception includes the expected error message.
238+ """
239+ with self.assertRaises(app.ProgramExit) as context_manager:
240+ yield
241+ expected = 'juju-quickstart: error: {}'.format(error)
242+ self.assertEqual(expected, str(context_manager.exception))
243+
244+
245+class TestBootstrap(
246+ helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):
247+
248+ env_name = 'ec2'
249+
250+ def make_status_output(self, agent_state):
251+ """Create and return a YAML status output."""
252+ return yaml.safe_dump({
253+ 'machines': {'0': {'agent-state': agent_state}},
254+ })
255+
256+ def make_status_calls(self, number):
257+ """Return a list containing the given number of status calls."""
258+ return [
259+ mock.call('juju', 'status', '-e', self.env_name)
260+ for _ in range(number)
261+ ]
262+
263+ def assert_status_retried(self, side_effects):
264+ """Ensure the "juju status" command is retried several times.
265+
266+ Receive the list of side effects the mock status call will return.
267+ """
268+ with self.patch_multiple_calls(side_effects) as mock_call:
269+ app.bootstrap(self.env_name)
270+ mock_call.assert_has_calls([
271+ mock.call('juju', 'bootstrap', '-e', self.env_name),
272+ ] + self.make_status_calls(5))
273+
274+ def test_success(self):
275+ # The environment is successfully bootstrapped.
276+ side_effects = [
277+ (0, '', ''), # Add a bootstrap call.
278+ (0, self.make_status_output('started'), ''), # Add a status call.
279+ ]
280+ with self.patch_multiple_calls(side_effects) as mock_call:
281+ app.bootstrap(self.env_name)
282+ mock_call.assert_has_calls([
283+ mock.call('juju', 'bootstrap', '-e', self.env_name),
284+ ] + self.make_status_calls(1))
285+
286+ def test_bootstrap_failure(self):
287+ # A ProgramExit is raised if an error occurs while bootstrapping.
288+ with self.patch_call(1, error='bad wolf') as mock_call:
289+ with self.assert_program_exit('bad wolf'):
290+ app.bootstrap(self.env_name)
291+ mock_call.assert_called_once_with(
292+ 'juju', 'bootstrap', '-e', self.env_name)
293+
294+ def test_status_retry_error(self):
295+ # Before raising a ProgramExit, the functions tries to call
296+ # "juju status" multiple times if it exits with an error.
297+ side_effects = [
298+ (0, '', ''), # Add the bootstrap call.
299+ # Add four status calls with a non-zero exit code.
300+ (1, '', 'these'),
301+ (2, '', 'are'),
302+ (3, '', 'the'),
303+ (4, '', 'voyages'),
304+ # Add a final valid status call.
305+ (0, self.make_status_output('started'), ''),
306+ ]
307+ self.assert_status_retried(side_effects)
308+
309+ def test_status_retry_invalid_output(self):
310+ # Before raising a ProgramExit, the functions tries to call
311+ # "juju status" multiple times if its output is not well formed or if
312+ # the agent is not started.
313+ side_effects = [
314+ (0, '', ''), # Add the bootstrap call.
315+ (0, '', ''), # Add the first status call: no output.
316+ (0, ':', ''), # Add the second status call: not YAML.
317+ (0, 'just-a-string', ''), # Add the third status call: bad YAML.
318+ # Add the fourth status call: the agent is still pending.
319+ (0, self.make_status_output('pending'), ''),
320+ # Add a final valid status call.
321+ (0, self.make_status_output('started'), ''),
322+ ]
323+ self.assert_status_retried(side_effects)
324+
325+ def test_status_retry_both(self):
326+ # Before raising a ProgramExit, the functions tries to call
327+ # "juju status" multiple times in any case.
328+ side_effects = [
329+ (0, '', ''), # Add the bootstrap call.
330+ (1, '', 'error'), # Add the first status call: error.
331+ (2, '', 'another error'), # Add the second status call: error.
332+ # Add the third status call: the agent is still pending.
333+ (0, self.make_status_output('pending'), ''),
334+ (0, 'just-a-string', ''), # Add the fourth status call: bad YAML.
335+ # Add a final valid status call.
336+ (0, self.make_status_output('started'), ''),
337+ ]
338+ self.assert_status_retried(side_effects)
339+
340+ def test_status_failure(self):
341+ # A ProgramExit is raised if "juju status" keeps failing.
342+ status_side_effects = [
343+ (i, 'output #{}\n'.format(i), 'error #{}\n'.format(i))
344+ for i in range(5)
345+ ]
346+ side_effects = [(0, '', '')] + status_side_effects
347+ expected = 'the state server is not ready:\noutput #4\nerror #4'
348+ with self.patch_multiple_calls(side_effects) as mock_call:
349+ with self.assert_program_exit(expected):
350+ app.bootstrap(self.env_name)
351+ mock_call.assert_has_calls([
352+ mock.call('juju', 'bootstrap', '-e', self.env_name),
353+ ] + self.make_status_calls(5))
354+
355+
356+class TestGetApiUrl(
357+ helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):
358+
359+ env_name = 'ec2'
360+
361+ def test_success(self):
362+ # The API URL is correctly returned.
363+ api_addresses = json.dumps(['api.example.com:17070', 'not-today'])
364+ with self.patch_call(0, output=api_addresses) as mock_call:
365+ api_url = app.get_api_url(self.env_name)
366+ self.assertEqual('wss://api.example.com:17070', api_url)
367+ mock_call.assert_called_once_with(
368+ 'juju', 'api-endpoints', '-e', self.env_name, '--format', 'json')
369+
370+ def test_failure(self):
371+ # A ProgramExit is raised if an error occurs retrieving the API URL.
372+ with self.patch_call(1, error='bad wolf') as mock_call:
373+ with self.assert_program_exit('bad wolf'):
374+ app.get_api_url(self.env_name)
375+ mock_call.assert_called_once_with(
376+ 'juju', 'api-endpoints', '-e', self.env_name, '--format', 'json')
377
378=== modified file 'quickstart/tests/test_manage.py'
379--- quickstart/tests/test_manage.py 2013-10-15 18:25:57 +0000
380+++ quickstart/tests/test_manage.py 2013-10-16 15:23:20 +0000
381@@ -17,6 +17,7 @@
382 """Tests for the Juju Quickstart management infrastructure."""
383
384 import argparse
385+import logging
386 import os
387 import unittest
388
389@@ -122,16 +123,17 @@
390 path = 'quickstart.manage.utils.get_default_env_name'
391 return mock.patch(path, mock_get_default_env_name)
392
393- def call_setup(self, args, env_name=None):
394+ def call_setup(self, args, env_name=None, exit_called=True):
395 """Call the setup function simulating the given args and env name.
396
397- Also ensure the program exits without errors.
398+ Also ensure the program exits without errors if exit_called is True.
399 """
400 with mock.patch('sys.argv', ['juju-quickstart'] + args):
401 with mock.patch('sys.exit') as mock_exit:
402 with self.patch_get_default_env_name(env_name):
403 manage.setup()
404- mock_exit.assert_called_once_with(0)
405+ if exit_called:
406+ mock_exit.assert_called_once_with(0)
407
408 def test_help(self):
409 # The program help message is properly formatted.
410@@ -170,3 +172,15 @@
411 self.call_setup(['--version'])
412 expected = 'juju-quickstart {}\n'.format(quickstart.get_version())
413 mock_stderr.write.assert_called_once_with(expected)
414+
415+ def test_configure_logging(self):
416+ # Logging is properly set up at the info level.
417+ logger = logging.getLogger()
418+ self.call_setup([], 'ec2', exit_called=False)
419+ self.assertEqual(logging.INFO, logger.level)
420+
421+ def test_configure_logging_debug(self):
422+ # Logging is properly set up at the debug level.
423+ logger = logging.getLogger()
424+ self.call_setup(['--debug'], 'ec2', exit_called=False)
425+ self.assertEqual(logging.DEBUG, logger.level)
426
427=== modified file 'quickstart/tests/test_utils.py'
428--- quickstart/tests/test_utils.py 2013-10-15 18:25:57 +0000
429+++ quickstart/tests/test_utils.py 2013-10-16 15:23:20 +0000
430@@ -61,13 +61,17 @@
431 'no-such-command: [Errno 2] No such file or directory',
432 error)
433
434-
435-class TestGetDefaultEnvName(unittest.TestCase):
436-
437- def patch_call(self, retcode, output='', error=''):
438- """Patch the quickstart.utils.call function."""
439- mock_call = mock.Mock(return_value=(retcode, output, error))
440- return mock.patch('quickstart.utils.call', mock_call)
441+ def test_logging(self):
442+ # The command line call and the results are properly logged.
443+ expected_messages = (
444+ "running the following: echo 'we are the borg!'",
445+ r"retcode: 0 | output: 'we are the borg!\n' | error: ''",
446+ )
447+ with helpers.assert_logs(expected_messages):
448+ utils.call('echo', 'we are the borg!')
449+
450+
451+class TestGetDefaultEnvName(helpers.CallTestsMixin, unittest.TestCase):
452
453 def test_environment_variable(self):
454 # The environment name is successfully returned if JUJU_ENV is set.
455@@ -176,3 +180,39 @@
456 env_type, admin_secret = utils.parse_env_file(env_file, 'aws')
457 self.assertEqual('ec2', env_type)
458 self.assertEqual('Secret!', admin_secret)
459+
460+
461+class TestParseStatusOutput(helpers.ValueErrorTestsMixin, unittest.TestCase):
462+
463+ def test_invalid_yaml(self):
464+ # A ValueError is raised if the output is not a valid YAML.
465+ with self.assertRaises(ValueError) as context_manager:
466+ utils.parse_status_output(':')
467+ expected = 'unable to parse the output'
468+ self.assertIn(expected, str(context_manager.exception))
469+
470+ def test_invalid_yaml_contents(self):
471+ # A ValueError is raised if the output is not well formed.
472+ with self.assert_value_error('invalid YAML contents: a-string'):
473+ utils.parse_status_output('a-string')
474+
475+ def test_no_env(self):
476+ # A ValueError is raised if the agent-state is not found in the YAML.
477+ data = {
478+ 'machines': {
479+ '0': {'agent-version': '1.17.0.1'},
480+ },
481+ }
482+ expected = 'agent state not found in {}'.format(str(data))
483+ with self.assert_value_error(expected):
484+ utils.parse_status_output(yaml.safe_dump(data))
485+
486+ def test_success(self):
487+ # The agent state is correctly returned.
488+ output = yaml.safe_dump({
489+ 'machines': {
490+ '0': {'agent-version': '1.17.0.1', 'agent-state': 'started'},
491+ },
492+ })
493+ agent_state = utils.parse_status_output(output)
494+ self.assertEqual('started', agent_state)
495
496=== modified file 'quickstart/utils.py'
497--- quickstart/utils.py 2013-10-15 15:47:31 +0000
498+++ quickstart/utils.py 2013-10-16 15:23:20 +0000
499@@ -18,6 +18,8 @@
500
501 import re
502 import os
503+import logging
504+import pipes
505 import subprocess
506
507 import yaml
508@@ -34,6 +36,8 @@
509 Return a tuple containing the subprocess return code, output and error.
510 """
511 pipe = subprocess.PIPE
512+ cmdline = ' '.join(map(pipes.quote, args))
513+ logging.debug('running the following: {}'.format(cmdline))
514 try:
515 process = subprocess.Popen(args, stdout=pipe, stderr=pipe)
516 except OSError as err:
517@@ -41,20 +45,32 @@
518 # found in the PATH.
519 return 127, '', '{}: {}'.format(args[0], err)
520 output, error = process.communicate()
521- return process.poll(), output, error
522+ retcode = process.poll()
523+ logging.debug('retcode: {} | output: {!r} | error: {!r}'.format(
524+ retcode, output, error))
525+ return retcode, output, error
526
527
528 def get_default_env_name():
529 """Return the current Juju environment name.
530
531- The environment name can be set either by setting the JUJU_ENV environment
532- variable or by using "juju switch". The former overrides the latter.
533+ The environment name can be set either by
534+ - setting the JUJU_ENV environment variable;
535+ - using "juju switch my-env-name";
536+ - setting the default environment in the environments.yaml file.
537+ The former overrides the latter.
538
539 Return None if a default environment is not found.
540 """
541 env_name = os.getenv('JUJU_ENV', '').strip()
542 if env_name:
543 return env_name
544+ # XXX 2013-10-16 frankban bug=1193244:
545+ # Switch to using "juju switch --raw" in order to avoid the fragility
546+ # of parsing the command output.
547+ # The "juju switch" command parses ~/.juju/current-environment file. If the
548+ # environment name is not found there, then it tries to retrieve the name
549+ # from the "default" section of the ~/.juju/environments.yaml file.
550 retcode, output, _ = call('juju', 'switch')
551 if retcode:
552 return None
553@@ -64,7 +80,7 @@
554
555
556 def parse_env_file(env_file, env_name):
557- """Parse the provided Juju environment.yaml file.
558+ """Parse the provided Juju environments.yaml file.
559
560 Return a tuple containing the provider type and the admin secret associated
561 with the given environment name.
562@@ -108,3 +124,22 @@
563 msg = '{} admin secret not found in {}'.format(env_name, env_file)
564 raise ValueError(msg)
565 return env_type, admin_secret
566+
567+
568+def parse_status_output(output):
569+ """Parse the output of juju status.
570+
571+ Return the agent state.
572+ Raise a ValueError if the agent state cannot be retrieved.
573+ """
574+ try:
575+ status = yaml.safe_load(output)
576+ except Exception as err:
577+ raise ValueError('unable to parse the output: {}'.format(err))
578+ try:
579+ state = status.get('machines', {}).get('0', {}).get('agent-state')
580+ except AttributeError as err:
581+ raise ValueError('invalid YAML contents: {}'.format(status))
582+ if state is None:
583+ raise ValueError('agent state not found in {}'.format(status))
584+ return state

Subscribers

People subscribed via source and target branches