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

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 6
Proposed branch: lp:~frankban/juju-quickstart/quickstart-watch
Merge into: lp:juju-quickstart
Diff against target: 399 lines (+298/-8)
6 files modified
quickstart/__init__.py (+1/-1)
quickstart/app.py (+55/-1)
quickstart/manage.py (+10/-1)
quickstart/tests/test_app.py (+169/-3)
quickstart/tests/test_utils.py (+48/-0)
quickstart/utils.py (+15/-2)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/quickstart-watch
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+191801@code.launchpad.net

Description of the change

Wait for the GUI to be ready and started.

Watch unit changes, open the browser at the
end of the process.

Tests: run `make check`.

QA assuming you have an ec2 Juju env named 'ec2':
- run `.venv/bin/python juju-quickstart -e ec2`.
The environment is bootstrapped, the GUI deployed,
relevant events on the GUI unit notified.
Your default browser shows the GUI when done.

https://codereview.appspot.com/15020043/

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

Reviewers: mp+191801_code.launchpad.net,

Message:
Please take a look.

Description:
Wait for the GUI to be ready and started.

Watch unit changes, open the browser at the
end of the process.

Tests: run `make check`.

QA assuming you have an ec2 Juju env named 'ec2':
- run `.venv/bin/python juju-quickstart -e ec2`.
The environment is bootstrapped, the GUI deployed,
relevant events on the GUI unit notified.
Your default browser shows the GUI when done.

https://code.launchpad.net/~frankban/juju-quickstart/quickstart-watch/+merge/191801

(do not edit description out of merge proposal)

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

Affected files (+296, -3 lines):
   A [revision details]
   M quickstart/__init__.py
   M quickstart/app.py
   M quickstart/manage.py
   M quickstart/tests/test_app.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

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

code LGTM with small comment about comment. Trying qa now.

https://codereview.appspot.com/15020043/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/15020043/diff/1/quickstart/app.py#newcode127
quickstart/app.py:127: def watch(env, service_name):
Nicely readable.

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

https://codereview.appspot.com/15020043/diff/1/quickstart/manage.py#newcode153
quickstart/manage.py:153: # there is no attached terminal.
I think the looking for an attached terminal is only important if we are
trying to ask the user for input. I originally thought we would ask the
user if he/she wanted us to open the browser, but in retrospect I think
simply defaulting to opening the browser, and having a flag to disable
this behavior, is sufficient. And fast. :-)

https://codereview.appspot.com/15020043/diff/1/quickstart/tests/test_app.py
File quickstart/tests/test_app.py (right):

https://codereview.appspot.com/15020043/diff/1/quickstart/tests/test_app.py#newcode310
quickstart/tests/test_app.py:310: # The glorious moments in the unit's
life are properly highlighted.
LOL

https://codereview.appspot.com/15020043/

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

https://codereview.appspot.com/15020043/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/15020043/diff/1/quickstart/app.py#newcode119
quickstart/app.py:119: charm_url = 'cs:precise/juju-gui-77'
Switch to 78? :-) This should make the deployment a lot faster since 77
is 40-something M and 78 is about 7M, IIRC.

https://codereview.appspot.com/15020043/

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

make check: good. The obsessive compulsive in me wants to see coverage
for the run function, so we get 100% ;-)

I had some issues in QA but they seemed Juju-related, not quickstart
related, and as you suggested, my system has an old Juju. I will update
and try again.

During the qa, though, the text did confuse me.

---
connecting to wss://ec2-50-17-120-194.compute-1.amazonaws.com:17070
deploying Juju GUI
service deployed and exposed
juju-gui/0 deployment is pending
---

What would you think of changing the second and third lines to something
like these?

---
connecting to wss://ec2-50-17-120-194.compute-1.amazonaws.com:17070
requesting Juju GUI deployment
Juju GUI deployment request accepted
juju-gui/0 deployment is pending
---

Better ideas welcome, but I think it is weird to see "deployed and
exposed" but then "deployment is pending".

https://codereview.appspot.com/15020043/

17. By Francesco Banconi

Changes as per review.

18. By Francesco Banconi

Fixed tests.

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

Please take a look.

https://codereview.appspot.com/15020043/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/15020043/diff/1/quickstart/app.py#newcode119
quickstart/app.py:119: charm_url = 'cs:precise/juju-gui-77'
On 2013/10/18 13:27:58, gary.poster wrote:
> Switch to 78? :-) This should make the deployment a lot faster since
77 is
> 40-something M and 78 is about 7M, IIRC.

Yeah, and it also includes the string encoding fix for the GUI server.

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

https://codereview.appspot.com/15020043/diff/1/quickstart/manage.py#newcode153
quickstart/manage.py:153: # there is no attached terminal.
On 2013/10/18 13:11:40, gary.poster wrote:
> I think the looking for an attached terminal is only important if we
are trying
> to ask the user for input. I originally thought we would ask the user
if he/she
> wanted us to open the browser, but in retrospect I think simply
defaulting to
> opening the browser, and having a flag to disable this behavior, is
sufficient.
> And fast. :-)

Cool, changed the XXX comment and the related card description.

https://codereview.appspot.com/15020043/

19. By Francesco Banconi

Other changes from the review.

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

qa good. Very cool. Thank you!

https://codereview.appspot.com/15020043/

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

On 2013/10/18 13:45:07, gary.poster wrote:
> make check: good. The obsessive compulsive in me wants to see
coverage for the
> run function, so we get 100% ;-)

> I had some issues in QA but they seemed Juju-related, not quickstart
related,
> and as you suggested, my system has an old Juju. I will update and
try again.

> During the qa, though, the text did confuse me.

> ---
> connecting to wss://ec2-50-17-120-194.compute-1.amazonaws.com:17070
> deploying Juju GUI
> service deployed and exposed
> juju-gui/0 deployment is pending
> ---

> What would you think of changing the second and third lines to
something like
> these?

> ---
> connecting to wss://ec2-50-17-120-194.compute-1.amazonaws.com:17070
> requesting Juju GUI deployment
> Juju GUI deployment request accepted
> juju-gui/0 deployment is pending
> ---

> Better ideas welcome, but I think it is weird to see "deployed and
exposed" but
> then "deployment is pending".

Done, thanks for the review Gary!

https://codereview.appspot.com/15020043/

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

*** Submitted:

Wait for the GUI to be ready and started.

Watch unit changes, open the browser at the
end of the process.

Tests: run `make check`.

QA assuming you have an ec2 Juju env named 'ec2':
- run `.venv/bin/python juju-quickstart -e ec2`.
The environment is bootstrapped, the GUI deployed,
relevant events on the GUI unit notified.
Your default browser shows the GUI when done.

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

https://codereview.appspot.com/15020043/

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-14 11:27:50 +0000
3+++ quickstart/__init__.py 2013-10-18 14:16:02 +0000
4@@ -19,7 +19,7 @@
5 that it can be managed using a Web interface (the Juju GUI).
6 """
7
8-VERSION = (0, 0, 1)
9+VERSION = (0, 1, 0)
10
11
12 def get_version():
13
14=== modified file 'quickstart/app.py'
15--- quickstart/app.py 2013-10-17 16:51:55 +0000
16+++ quickstart/app.py 2013-10-18 14:16:02 +0000
17@@ -16,6 +16,8 @@
18
19 """Juju Quickstart base application functions."""
20
21+from __future__ import print_function
22+import functools
23 import json
24
25 import jujuclient
26@@ -114,9 +116,61 @@
27 # XXX 2013-10-17 frankban:
28 # Retrieve the URL of the last charm revision from
29 # manage.jujucharms.com.
30- charm_url = 'cs:precise/juju-gui-77'
31+ charm_url = 'cs:precise/juju-gui-78'
32 try:
33 env.deploy(service_name, charm_url, to=0)
34 env.expose(service_name)
35 except jujuclient.EnvError as err:
36 raise ProgramExit('bad API server response: {}'.format(err.message))
37+
38+
39+def watch(env, service_name):
40+ """Start watching the first unit belonging to the given service.
41+
42+ Output a human readable messages from each time a relevant change is found.
43+
44+ The following changes are considered relevant for a healthy unit:
45+ - the unit is pending;
46+ - the unit is reachable;
47+ - the unit is installed;
48+ - the unit is started.
49+
50+ Stop watching and return the unit public address when the unit is started.
51+ Raise a ProgramExit if the API server returns an error response, or if
52+ the service unit is removed or in error.
53+ """
54+ unit_name = '{}/0'.format(service_name)
55+ processor = functools.partial(utils.unit_changes, unit_name)
56+ watcher = env.watch_changes(processor)
57+ address = current_status = ''
58+ while True:
59+ try:
60+ entity, action, data = watcher.next()
61+ except jujuclient.EnvError as err:
62+ raise ProgramExit(
63+ 'bad API server response: {}'.format(err.message))
64+ # Exit with an error if the unit is removed.
65+ if action == 'remove':
66+ raise ProgramExit('{} unexpectedly removed'.format(unit_name))
67+ # Exit with an error if the unit is in an error state.
68+ status = data['Status']
69+ if 'error' in status:
70+ msg = '{} is in an error state: {}: {}'.format(
71+ unit_name, status, data['StatusInfo'])
72+ raise ProgramExit(msg)
73+ # Notify when the unit becomes reachable.
74+ if not address:
75+ address = data['PublicAddress']
76+ if address:
77+ print('installing {} on {}'.format(unit_name, address))
78+ # Notify status changes.
79+ if status != current_status:
80+ if status == 'pending':
81+ print('{} deployment is pending'.format(unit_name))
82+ elif status == 'installed':
83+ print('{} is installed'.format(unit_name))
84+ elif address and status == 'started':
85+ # We can exit this loop.
86+ print('{} is ready'.format(unit_name))
87+ return address
88+ current_status = status
89
90=== modified file 'quickstart/manage.py'
91--- quickstart/manage.py 2013-10-17 16:51:55 +0000
92+++ quickstart/manage.py 2013-10-18 14:16:02 +0000
93@@ -20,6 +20,7 @@
94 import argparse
95 import logging
96 import os
97+import webbrowser
98
99 import quickstart
100 from quickstart import (
101@@ -141,5 +142,13 @@
102 api_url = app.get_api_url(options.env_name)
103 print('connecting to {}'.format(api_url))
104 env = app.connect(api_url, options.admin_secret)
105- print('deploying Juju GUI')
106+ print('requesting Juju GUI deployment')
107 app.deploy_gui(env, 'juju-gui')
108+ print('Juju GUI deployment request accepted')
109+ address = app.watch(env, 'juju-gui')
110+ url = 'https://{}'.format(address)
111+ print('url: {}\npassword: {}'.format(url, options.admin_secret))
112+ # XXX 2013-10-18 frankban:
113+ # Add a command line option to decline opening the browser.
114+ webbrowser.open(url)
115+ print('done!')
116
117=== modified file 'quickstart/tests/test_app.py'
118--- quickstart/tests/test_app.py 2013-10-17 16:52:40 +0000
119+++ quickstart/tests/test_app.py 2013-10-18 14:16:02 +0000
120@@ -240,7 +240,7 @@
121 env = mock.Mock()
122 app.deploy_gui(env, 'my-gui')
123 env.assert_has_calls([
124- mock.call.deploy('my-gui', 'cs:precise/juju-gui-77', to=0),
125+ mock.call.deploy('my-gui', 'cs:precise/juju-gui-78', to=0),
126 mock.call.expose('my-gui')
127 ])
128
129@@ -252,7 +252,7 @@
130 with self.assert_program_exit(expected):
131 app.deploy_gui(env, 'another-gui')
132 env.deploy.assert_called_once_with(
133- 'another-gui', 'cs:precise/juju-gui-77', to=0)
134+ 'another-gui', 'cs:precise/juju-gui-78', to=0)
135
136 def test_other_errors(self):
137 # Any other errors occurred during the process are not trapped.
138@@ -262,6 +262,172 @@
139 with self.assertRaises(ValueError) as context_manager:
140 app.deploy_gui(env, 'juju-gui')
141 env.deploy.assert_called_once_with(
142- 'juju-gui', 'cs:precise/juju-gui-77', to=0)
143+ 'juju-gui', 'cs:precise/juju-gui-78', to=0)
144 env.expose.assert_called_once_with('juju-gui')
145 self.assertIs(error, context_manager.exception)
146+
147+
148+@mock.patch('__builtin__.print')
149+class TestWatch(ProgramExitTestsMixin, unittest.TestCase):
150+
151+ address = 'unit.example.com'
152+ change_pending = ('unit', 'change', {
153+ 'Name': 'django/42',
154+ 'Status': 'pending',
155+ 'PublicAddress': '',
156+ })
157+ change_reachable = ('unit', 'change', {
158+ 'Name': 'django/42',
159+ 'Status': 'pending',
160+ 'PublicAddress': address,
161+ })
162+ change_installed = ('unit', 'change', {
163+ 'Name': 'django/42',
164+ 'Status': 'installed',
165+ 'PublicAddress': address,
166+ })
167+ change_started = ('unit', 'change', {
168+ 'Name': 'django/42',
169+ 'Status': 'started',
170+ 'PublicAddress': address,
171+ })
172+ pending_call = mock.call('django/0 deployment is pending')
173+ reachable_call = mock.call('installing django/0 on {}'.format(address))
174+ installed_call = mock.call('django/0 is installed')
175+ started_call = mock.call('django/0 is ready')
176+
177+ def make_env(self, changes):
178+ """Create and return a patched Environment instance.
179+
180+ The watch_changes method of the resulting Environment object returns
181+ the provided changes.
182+ """
183+ env = mock.Mock()
184+ env.watch_changes().next.side_effect = changes
185+ return env
186+
187+ def test_unit_life(self, mock_print):
188+ # The glorious moments in the unit's life are properly highlighted.
189+ env = self.make_env([
190+ self.change_pending,
191+ self.change_reachable,
192+ self.change_installed,
193+ self.change_started,
194+ ])
195+ address = app.watch(env, 'django')
196+ self.assertEqual(self.address, address)
197+ mock_print.assert_has_calls([
198+ self.pending_call,
199+ self.reachable_call,
200+ self.installed_call,
201+ self.started_call,
202+ ])
203+
204+ def test_weird_order(self, mock_print):
205+ # Strange unit evolutions are handled.
206+ env = self.make_env([
207+ self.change_reachable,
208+ self.change_pending,
209+ self.change_installed,
210+ self.change_started,
211+ ])
212+ address = app.watch(env, 'django')
213+ self.assertEqual(self.address, address)
214+ mock_print.assert_has_calls([
215+ self.reachable_call,
216+ self.pending_call,
217+ self.installed_call,
218+ self.started_call,
219+ ])
220+
221+ def test_missing_changes(self, mock_print):
222+ # Only the started change is strictly required.
223+ env = self.make_env([self.change_started])
224+ address = app.watch(env, 'django')
225+ self.assertEqual(self.address, address)
226+ mock_print.assert_has_calls([
227+ self.reachable_call,
228+ self.started_call,
229+ ])
230+
231+ def test_extraneous_changes(self, mock_print):
232+ # Extraneous changes are properly handled.
233+ env = self.make_env([
234+ self.change_pending,
235+ ('unit', 'change', {
236+ 'Name': 'django/42',
237+ 'Status': 'pending',
238+ 'PublicAddress': '',
239+ 'Series': 'precise',
240+ }),
241+ self.change_reachable,
242+ self.change_installed,
243+ ('unit', 'change', {
244+ 'Name': 'django/42',
245+ 'Status': 'installed',
246+ 'PublicAddress': self.address,
247+ 'Series': 'precise',
248+ 'MachineId': '3',
249+ }),
250+ self.change_started,
251+ ])
252+ address = app.watch(env, 'django')
253+ self.assertEqual(self.address, address)
254+ mock_print.assert_has_calls([
255+ self.pending_call,
256+ self.reachable_call,
257+ self.installed_call,
258+ self.started_call,
259+ ])
260+
261+ def test_api_error(self, mock_print):
262+ # A ProgramExit is raised if an error occurs in one of the API calls.
263+ env = self.make_env([
264+ self.change_pending,
265+ self.make_env_error('next returned an error'),
266+ ])
267+ expected = 'bad API server response: next returned an error'
268+ with self.assert_program_exit(expected):
269+ app.watch(env, 'django')
270+ mock_print.assert_has_calls([self.pending_call])
271+
272+ def test_other_errors(self, mock_print):
273+ # Any other errors occurred during the process are not trapped.
274+ error = ValueError('explode!')
275+ env = self.make_env([self.change_installed, error])
276+ with self.assertRaises(ValueError) as context_manager:
277+ app.watch(env, 'django')
278+ mock_print.assert_has_calls([self.reachable_call, self.installed_call])
279+ self.assertIs(error, context_manager.exception)
280+
281+ def test_unit_removed(self, mock_print):
282+ # A ProgramExit is raised if Juju removed the unit.
283+ env = self.make_env([
284+ self.change_pending,
285+ ('unit', 'remove', {
286+ 'Name': 'django/42',
287+ 'Status': 'pending',
288+ 'PublicAddress': '',
289+ }),
290+ self.change_reachable,
291+ ])
292+ with self.assert_program_exit('django/0 unexpectedly removed'):
293+ app.watch(env, 'django')
294+ mock_print.assert_has_calls([self.pending_call])
295+
296+ def test_status_error(self, mock_print):
297+ # A ProgramExit is raised if an the unit is found in an error state.
298+ env = self.make_env([
299+ self.change_pending,
300+ ('unit', 'change', {
301+ 'Name': 'django/42',
302+ 'Status': 'error',
303+ 'StatusInfo': 'install hook failure',
304+ 'PublicAddress': '',
305+ }),
306+ self.change_reachable,
307+ ])
308+ expected = 'django/0 is in an error state: error: install hook failure'
309+ with self.assert_program_exit(expected):
310+ app.watch(env, 'django')
311+ mock_print.assert_has_calls([self.pending_call])
312
313=== modified file 'quickstart/tests/test_utils.py'
314--- quickstart/tests/test_utils.py 2013-10-17 15:07:11 +0000
315+++ quickstart/tests/test_utils.py 2013-10-18 14:16:02 +0000
316@@ -236,3 +236,51 @@
317 def test_none(self):
318 # The None value is returned as is.
319 self.assertIsNone(utils.utf8(None))
320+
321+
322+class TestUnitChanges(unittest.TestCase):
323+
324+ unit = 'django/42'
325+
326+ def test_change_found(self):
327+ # A relevant change is correctly found and returned.
328+ change = ('unit', 'change', {'Name': self.unit, 'Status': 'pending'})
329+ self.assertEqual(change, utils.unit_changes(self.unit, [change]))
330+
331+ def test_change_found_removed(self):
332+ # A unit removal is considered a relevant change.
333+ change = ('unit', 'remove', {'Name': self.unit, 'Status': 'started'})
334+ self.assertEqual(change, utils.unit_changes(self.unit, [change]))
335+
336+ def test_not_a_unit(self):
337+ # Changes to other entities are ignored.
338+ change = ('service', 'change', {'Name': 'django', 'Exposed': True})
339+ self.assertIsNone(utils.unit_changes(self.unit, [change]))
340+
341+ def test_not_a_specific_unit(self):
342+ # Changes to other units are ignored.
343+ change = ('unit', 'change', {'Name': 'django/0', 'Status': 'pending'})
344+ self.assertIsNone(utils.unit_changes(self.unit, [change]))
345+
346+ def test_multiple_changes(self):
347+ # A relevant change is found between multiple ones.
348+ change = ('unit', 'change', {'Name': self.unit, 'Status': 'pending'})
349+ changeset = [
350+ ('machine', 'change', {'Id': '0', 'Status': 'started'}),
351+ change,
352+ ('service', 'change', {'Name': 'django', 'Exposed': True}),
353+ ]
354+ self.assertEqual(change, utils.unit_changes(self.unit, changeset))
355+
356+ def test_multiple_unit_changes(self):
357+ # A changeset is not supposed to include multiple change for a single
358+ # unit. The function just picks the first change.
359+ data = {'Name': self.unit, 'Status': 'pending'}
360+ changeset = [
361+ ('unit', 'change', data),
362+ ('unit', 'remove', data),
363+ ('unit', 'exterminated', data),
364+ ]
365+ self.assertEqual(
366+ ('unit', 'change', data),
367+ utils.unit_changes(self.unit, changeset))
368
369=== modified file 'quickstart/utils.py'
370--- quickstart/utils.py 2013-10-17 15:07:11 +0000
371+++ quickstart/utils.py 2013-10-18 14:16:02 +0000
372@@ -66,8 +66,9 @@
373 if env_name:
374 return env_name
375 # XXX 2013-10-16 frankban bug=1193244:
376- # Switch to using "juju switch --raw" in order to avoid the fragility
377- # of parsing the command output.
378+ # Support the new behavior of juju-switch, currently under development:
379+ # the command will just output the environment name, or exit with an
380+ # error if no default environment is configured.
381 # The "juju switch" command parses ~/.juju/current-environment file. If the
382 # environment name is not found there, then it tries to retrieve the name
383 # from the "default" section of the ~/.juju/environments.yaml file.
384@@ -153,3 +154,15 @@
385 if isinstance(value, unicode):
386 return value.encode('utf-8')
387 return value
388+
389+
390+def unit_changes(unit_name, changeset):
391+ """Parse the changeset and return the change related to the given unit.
392+
393+ This function is intended to be used as a processor callable for the
394+ watch_changes method of quickstart.juju.Environment.
395+ """
396+ for change in changeset:
397+ entity, _, data = change
398+ if entity == 'unit' and data['Name'] == unit_name:
399+ return change

Subscribers

People subscribed via source and target branches