Merge lp:~frankban/juju-quickstart/quickstart-watch into lp:juju-quickstart
- quickstart-watch
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email: mp+191801@code.launchpad.net |
Commit message
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.
Francesco Banconi (frankban) wrote : | # |
Gary Poster (gary) wrote : | # |
code LGTM with small comment about comment. Trying qa now.
https:/
File quickstart/app.py (right):
https:/
quickstart/
Nicely readable.
https:/
File quickstart/
https:/
quickstart/
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:/
File quickstart/
https:/
quickstart/
life are properly highlighted.
LOL
Gary Poster (gary) wrote : | # |
https:/
File quickstart/app.py (right):
https:/
quickstart/
Switch to 78? :-) This should make the deployment a lot faster since 77
is 40-something M and 78 is about 7M, IIRC.
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-
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-
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".
- 17. By Francesco Banconi
-
Changes as per review.
- 18. By Francesco Banconi
-
Fixed tests.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
https:/
File quickstart/app.py (right):
https:/
quickstart/
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:/
File quickstart/
https:/
quickstart/
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.
- 19. By Francesco Banconi
-
Other changes from the review.
Gary Poster (gary) wrote : | # |
qa good. Very cool. Thank you!
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-
> 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-
> 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!
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:/
Preview Diff
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 |
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): __init_ _.py manage. py tests/test_ app.py tests/test_ utils.py
A [revision details]
M quickstart/
M quickstart/app.py
M quickstart/
M quickstart/
M quickstart/
M quickstart/utils.py