Merge lp:~frankban/juju-quickstart/handle-machine-errors into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 47
Proposed branch: lp:~frankban/juju-quickstart/handle-machine-errors
Merge into: lp:juju-quickstart
Diff against target: 982 lines (+618/-182)
7 files modified
quickstart/app.py (+39/-34)
quickstart/manage.py (+6/-4)
quickstart/tests/test_app.py (+189/-84)
quickstart/tests/test_utils.py (+0/-48)
quickstart/tests/test_watchers.py (+266/-0)
quickstart/utils.py (+0/-12)
quickstart/watchers.py (+118/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/handle-machine-errors
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+202105@code.launchpad.net

Description of the change

Improve machine errors handling.

Quickstart no longer hangs when the GUI
machine goes in an "error" state and, as
a consequence, the unit is forever in
a "pending" state.

Also fixed the message printed at the
end of the process suggesting how to
destroy the environment
(sudo is used where required).

Tests: `make check`.

QA: I already asked Rick to reproduce the trusty/LXC
errors he encountered QAing my previous branch.
The GUI no longer hangs.
I'd also appreciate any other suggestion and additional
QA, given that this branch is a good candidate for 1.0.

https://codereview.appspot.com/51350044/

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

Reviewers: mp+202105_code.launchpad.net,

Message:
Please take a look.

Description:
Improve machine errors handling.

Quickstart no longer hangs when the GUI
machine goes in an "error" state and, as
a consequence, the unit is forever in
a "pending" state.

Also fixed the message printed at the
end of the process suggesting how to
destroy the environment
(sudo is used where required).

Tests: `make check`.

QA: I already asked Rick to reproduce the trusty/LXC
errors he encountered QAing my previous branch.
The GUI no longer hangs.
I'd also appreciate any other suggestion and additional
QA, given that this branch is a good candidate for 1.0.

https://code.launchpad.net/~frankban/juju-quickstart/handle-machine-errors/+merge/202105

(do not edit description out of merge proposal)

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

Affected files (+616, -180 lines):
   A [revision details]
   M quickstart/app.py
   M quickstart/manage.py
   M quickstart/tests/test_app.py
   M quickstart/tests/test_utils.py
   A quickstart/tests/test_watchers.py
   M quickstart/utils.py
   A quickstart/watchers.py

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

Code LGTM. Have not yet done QA.

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

https://codereview.appspot.com/51350044/diff/1/quickstart/app.py#newcode467
quickstart/app.py:467: # specific unit. For this reason, we exit the for
loop here.
You don't sound convinced. Any chance that assumption is wrong? The
penalty for looping over the rest non-matching items is trivial. But if
you're sure, maybe keep this logic but change the comment to be more
confident.

https://codereview.appspot.com/51350044/diff/1/quickstart/app.py#newcode481
quickstart/app.py:481: # We assume the mega-watcher contains a single
change for each
As above.

https://codereview.appspot.com/51350044/

54. By Francesco Banconi

Changes as per review.

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

Please take a look.

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

https://codereview.appspot.com/51350044/diff/1/quickstart/app.py#newcode467
quickstart/app.py:467: # specific unit. For this reason, we exit the for
loop here.
On 2014/01/17 15:20:15, bac wrote:
> You don't sound convinced. Any chance that assumption is wrong? The
penalty
> for looping over the rest non-matching items is trivial. But if
you're sure,
> maybe keep this logic but change the comment to be more confident.

Done.

https://codereview.appspot.com/51350044/diff/1/quickstart/app.py#newcode481
quickstart/app.py:481: # We assume the mega-watcher contains a single
change for each
On 2014/01/17 15:20:15, bac wrote:
> As above.

Done.

https://codereview.appspot.com/51350044/

Revision history for this message
Richard Harding (rharding) wrote :

LGTM thanks for the updates and a lot more tests.

https://codereview.appspot.com/51350044/

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

qa-ok, modulo the issue we talked about on IRC.

https://codereview.appspot.com/51350044/

55. By Francesco Banconi

Fix typo.

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

*** Submitted:

Improve machine errors handling.

Quickstart no longer hangs when the GUI
machine goes in an "error" state and, as
a consequence, the unit is forever in
a "pending" state.

Also fixed the message printed at the
end of the process suggesting how to
destroy the environment
(sudo is used where required).

Tests: `make check`.

QA: I already asked Rick to reproduce the trusty/LXC
errors he encountered QAing my previous branch.
The GUI no longer hangs.
I'd also appreciate any other suggestion and additional
QA, given that this branch is a good candidate for 1.0.

R=bac, rharding
CC=
https://codereview.appspot.com/51350044

https://codereview.appspot.com/51350044/

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
=== modified file 'quickstart/app.py'
--- quickstart/app.py 2014-01-08 21:25:36 +0000
+++ quickstart/app.py 2014-01-17 18:31:35 +0000
@@ -21,7 +21,6 @@
21 unicode_literals,21 unicode_literals,
22)22)
2323
24import functools
25import json24import json
26import logging25import logging
27import os26import os
@@ -34,6 +33,7 @@
34 juju,33 juju,
35 settings,34 settings,
36 utils,35 utils,
36 watchers,
37)37)
3838
3939
@@ -427,55 +427,60 @@
427427
428428
429def watch(env, unit_name):429def watch(env, unit_name):
430 """Start watching the given unit.430 """Start watching the given unit and the machine the unit belongs to.
431431
432 Output a human readable messages from each time a relevant change is found.432 Output a human readable message each time a relevant change is found.
433433
434 The following changes are considered relevant for a healthy unit:434 The following changes are considered relevant for a healthy unit:
435 - the machine is pending;
435 - the unit is pending;436 - the unit is pending;
437 - the machine is started;
436 - the unit is reachable;438 - the unit is reachable;
437 - the unit is installed;439 - the unit is installed;
438 - the unit is started.440 - the unit is started.
439441
440 Stop watching and return the unit public address when the unit is started.442 Stop watching and return the unit public address when the unit is started.
441 Raise a ProgramExit if the API server returns an error response, or if443 Raise a ProgramExit if the API server returns an error response, or if
442 the service unit is removed or in error.444 either the service unit or the machine is removed or in error.
443 """445 """
444 processor = functools.partial(utils.unit_changes, unit_name)446 address = unit_status = machine_id = machine_status = ''
445 watcher = env.watch_changes(processor)447 watcher = env.watch_changes(watchers.unit_machine_changes)
446 address = current_status = ''
447 while True:448 while True:
448 try:449 try:
449 entity, action, data = watcher.next()450 unit_changes, machine_changes = watcher.next()
450 except jujuclient.EnvError as err:451 except jujuclient.EnvError as err:
451 raise ProgramExit(452 raise ProgramExit(
452 'bad API server response: {}'.format(err.message))453 'bad API server response: {}'.format(err.message))
453 # Exit with an error if the unit is removed.454 # Process unit changes:
454 if action == 'remove':455 for action, data in unit_changes:
455 raise ProgramExit('{} unexpectedly removed'.format(unit_name))456 if data['Name'] == unit_name:
456 # Exit with an error if the unit is in an error state.457 try:
457 status = data['Status']458 data = watchers.parse_unit_change(
458 if 'error' in status:459 action, data, unit_status, address)
459 msg = '{} is in an error state: {}: {}'.format(460 except ValueError as err:
460 unit_name, status, data['StatusInfo'])461 raise ProgramExit(bytes(err))
461 raise ProgramExit(msg)462 unit_status, address, machine_id = data
462 # Notify when the unit becomes reachable.463 if address and unit_status == 'started':
463 if not address:464 # We can exit this loop.
464 address = data['PublicAddress']465 return address
465 if address:466 # The mega-watcher contains a single change for each specific
466 print('setting up {} on {}'.format(unit_name, address))467 # unit. For this reason, we can exit the for loop here.
467 # Notify status changes.468 break
468 if status != current_status:469 if not machine_id:
469 if status == 'pending':470 # No need to process machine changes: we still don't know what
470 print('{} deployment is pending'.format(unit_name))471 # machine the unit belongs to.
471 elif status == 'installed':472 continue
472 print('{} is installed'.format(unit_name))473 # Process machine changes.
473 elif address and status == 'started':474 for action, data in machine_changes:
474 # We can exit this loop.475 if data['Id'] == machine_id:
475 print('{} is ready on machine {}'.format(476 try:
476 unit_name, data['MachineId']))477 machine_status = watchers.parse_machine_change(
477 return address478 action, data, machine_status)
478 current_status = status479 except ValueError as err:
480 raise ProgramExit(bytes(err))
481 # The mega-watcher contains a single change for each specific
482 # machine. For this reason, we can exit the for loop here.
483 break
479484
480485
481def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id):486def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id):
482487
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2014-01-16 12:41:47 +0000
+++ quickstart/manage.py 2014-01-17 18:31:35 +0000
@@ -413,14 +413,16 @@
413413
414 # Handle bundle deployment.414 # Handle bundle deployment.
415 if options.bundle is not None:415 if options.bundle is not None:
416 print('deploying the bundle {} with the following services: {}'.format(416 services = ', '.join(options.bundle_services)
417 options.bundle_name, ', '.join(options.bundle_services)))417 print('requesting a deployment of the {} bundle with the following '
418 'services:\n {}'.format(options.bundle_name, services))
418 # We need to connect to an API WebSocket server supporting bundle419 # We need to connect to an API WebSocket server supporting bundle
419 # deployments. The GUI builtin server, listening on the Juju GUI420 # deployments. The GUI builtin server, listening on the Juju GUI
420 # address, exposes an API suitable for deploying bundles.421 # address, exposes an API suitable for deploying bundles.
421 app.deploy_bundle(422 app.deploy_bundle(
422 gui_env, options.bundle_yaml, options.bundle_name,423 gui_env, options.bundle_yaml, options.bundle_name,
423 options.bundle_id)424 options.bundle_id)
425 print('bundle deployment request accepted')
424426
425 if options.open_browser:427 if options.open_browser:
426 token = app.create_auth_token(gui_env)428 token = app.create_auth_token(gui_env)
@@ -435,7 +437,7 @@
435 'Run "juju quickstart -i" if you want to manage\n'437 'Run "juju quickstart -i" if you want to manage\n'
436 'or bootstrap your Juju environments using the\n'438 'or bootstrap your Juju environments using the\n'
437 'interactive session.\n'439 'interactive session.\n'
438 'Run "juju destroy-environment -e {env_name} [-y]"\n'440 'Run "{sudo}juju destroy-environment -e {env_name} [-y]"\n'
439 'to destroy the environment you just bootstrapped.'.format(441 'to destroy the environment you just bootstrapped.'.format(
440 env_name=options.env_name)442 env_name=options.env_name, sudo='sudo ' if is_local else '')
441 )443 )
442444
=== modified file 'quickstart/tests/test_app.py'
--- quickstart/tests/test_app.py 2014-01-08 21:25:36 +0000
+++ quickstart/tests/test_app.py 2014-01-17 18:31:35 +0000
@@ -1009,34 +1009,50 @@
10091009
10101010
1011@helpers.mock_print1011@helpers.mock_print
1012class TestWatch(ProgramExitTestsMixin, unittest.TestCase):1012class TestWatch(
1013 ProgramExitTestsMixin, helpers.ValueErrorTestsMixin,
1014 unittest.TestCase):
10131015
1014 address = 'unit.example.com'1016 address = 'unit.example.com'
1015 change_pending = ('unit', 'change', {1017 change_machine_pending = ('change', {
1018 'Id': '0',
1019 'Status': 'pending',
1020 })
1021 change_machine_started = ('change', {
1022 'Id': '0',
1023 'Status': 'started',
1024 })
1025 change_unit_pending = ('change', {
1026 'MachineId': '0',
1016 'Name': 'django/42',1027 'Name': 'django/42',
1017 'PublicAddress': '',1028 'PublicAddress': '',
1018 'Status': 'pending',1029 'Status': 'pending',
1019 })1030 })
1020 change_reachable = ('unit', 'change', {1031 change_unit_reachable = ('change', {
1032 'MachineId': '0',
1021 'Name': 'django/42',1033 'Name': 'django/42',
1022 'PublicAddress': address,1034 'PublicAddress': address,
1023 'Status': 'pending',1035 'Status': 'pending',
1024 })1036 })
1025 change_installed = ('unit', 'change', {1037 change_unit_installed = ('change', {
1038 'MachineId': '0',
1026 'Name': 'django/42',1039 'Name': 'django/42',
1027 'PublicAddress': address,1040 'PublicAddress': address,
1028 'Status': 'installed',1041 'Status': 'installed',
1029 })1042 })
1030 change_started = ('unit', 'change', {1043 change_unit_started = ('change', {
1031 'MachineId': '0',1044 'MachineId': '0',
1032 'Name': 'django/42',1045 'Name': 'django/42',
1033 'PublicAddress': address,1046 'PublicAddress': address,
1034 'Status': 'started',1047 'Status': 'started',
1035 })1048 })
1036 pending_call = mock.call('django/42 deployment is pending')1049 machine_pending_call = mock.call('machine 0 provisioning is pending')
1037 reachable_call = mock.call('setting up django/42 on {}'.format(address))1050 machine_started_call = mock.call('machine 0 is started')
1038 installed_call = mock.call('django/42 is installed')1051 unit_pending_call = mock.call('django/42 deployment is pending')
1039 started_call = mock.call('django/42 is ready on machine 0')1052 unit_reachable_call = mock.call(
1053 'setting up django/42 on {}'.format(address))
1054 unit_installed_call = mock.call('django/42 is installed')
1055 unit_started_call = mock.call('django/42 is ready on machine 0')
10401056
1041 def make_env(self, changes):1057 def make_env(self, changes):
1042 """Create and return a patched Environment instance.1058 """Create and return a patched Environment instance.
@@ -1048,131 +1064,220 @@
1048 env.watch_changes().next.side_effect = changes1064 env.watch_changes().next.side_effect = changes
1049 return env1065 return env
10501066
1067 # The following group of tests exercises both the function return value and
1068 # the function output, even if the output is handled by sub-functions.
1069 # This is done to simulate the different user experiences of observing the
1070 # environment evolution while the unit is deploying.
1071
1051 def test_unit_life(self, mock_print):1072 def test_unit_life(self, mock_print):
1052 # The glorious moments in the unit's life are properly highlighted.1073 # The glorious moments in the unit's life are properly highlighted.
1074 # The machine achievements are also celebrated.
1053 env = self.make_env([1075 env = self.make_env([
1054 self.change_pending,1076 ([self.change_unit_pending], [self.change_machine_pending]),
1055 self.change_reachable,1077 ([], [self.change_machine_started]),
1056 self.change_installed,1078 ([self.change_unit_reachable], []),
1057 self.change_started,1079 ([self.change_unit_installed], []),
1080 ([self.change_unit_started], []),
1058 ])1081 ])
1059 address = app.watch(env, 'django/42')1082 address = app.watch(env, 'django/42')
1060 self.assertEqual(self.address, address)1083 self.assertEqual(self.address, address)
1084 self.assertEqual(6, mock_print.call_count)
1061 mock_print.assert_has_calls([1085 mock_print.assert_has_calls([
1062 self.pending_call,1086 self.unit_pending_call,
1063 self.reachable_call,1087 self.machine_pending_call,
1064 self.installed_call,1088 self.machine_started_call,
1065 self.started_call,1089 self.unit_reachable_call,
1090 self.unit_installed_call,
1091 self.unit_started_call,
1066 ])1092 ])
10671093
1068 def test_weird_order(self, mock_print):1094 def test_weird_order(self, mock_print):
1069 # Strange unit evolutions are handled.1095 # Strange unit evolutions are handled.
1070 env = self.make_env([1096 env = self.make_env([
1071 self.change_reachable,1097 # The unit is first reachable and then pending. The machine starts
1072 self.change_pending,1098 # when the unit is already installed. All of this makes no sense
1073 self.change_installed,1099 # and should never happen, but if it does, we deal with it.
1074 self.change_started,1100 ([self.change_unit_reachable], []),
1101 ([self.change_unit_pending], [self.change_machine_pending]),
1102 ([self.change_unit_installed], []),
1103 ([], [self.change_machine_started]),
1104 ([self.change_unit_started], []),
1075 ])1105 ])
1076 address = app.watch(env, 'django/42')1106 address = app.watch(env, 'django/42')
1077 self.assertEqual(self.address, address)1107 self.assertEqual(self.address, address)
1108 self.assertEqual(6, mock_print.call_count)
1078 mock_print.assert_has_calls([1109 mock_print.assert_has_calls([
1079 self.reachable_call,1110 self.unit_reachable_call,
1080 self.pending_call,1111 self.unit_pending_call,
1081 self.installed_call,1112 self.machine_pending_call,
1082 self.started_call,1113 self.unit_installed_call,
1114 self.machine_started_call,
1115 self.unit_started_call,
1083 ])1116 ])
10841117
1085 def test_missing_changes(self, mock_print):1118 def test_missing_changes(self, mock_print):
1086 # Only the started change is strictly required.1119 # Only the unit started change is strictly required.
1087 env = self.make_env([self.change_started])1120 env = self.make_env([([self.change_unit_started], [])])
1088 address = app.watch(env, 'django/42')1121 address = app.watch(env, 'django/42')
1089 self.assertEqual(self.address, address)1122 self.assertEqual(self.address, address)
1090 mock_print.assert_has_calls([1123 self.assertEqual(2, mock_print.call_count)
1091 self.reachable_call,1124 mock_print.assert_has_calls([
1092 self.started_call,1125 self.unit_reachable_call,
1126 self.unit_started_call,
1127 ])
1128
1129 def test_ignored_machine_changes(self, mock_print):
1130 # All machine changes are ignored until the application knows what
1131 # machine the unit belongs to.
1132 env = self.make_env([
1133 ([], [self.change_machine_pending]),
1134 ([], [self.change_machine_started]),
1135 ([self.change_unit_started], []),
1136 ])
1137 address = app.watch(env, 'django/42')
1138 self.assertEqual(self.address, address)
1139 # No machine related messages have been printed.
1140 self.assertEqual(2, mock_print.call_count)
1141 mock_print.assert_has_calls([
1142 self.unit_reachable_call,
1143 self.unit_started_call,
1144 ])
1145
1146 def test_unit_already_deployed(self, mock_print):
1147 # Simulate the unit we are observing has been already deployed.
1148 # This happens, e.g., when executing Quickstart a second time, and both
1149 # the unit and the machine are already started.
1150 env = self.make_env([
1151 ([self.change_unit_started], [self.change_machine_started]),
1152 ])
1153 address = app.watch(env, 'django/42')
1154 self.assertEqual(self.address, address)
1155 self.assertEqual(2, mock_print.call_count)
1156
1157 def test_machine_already_started(self, mock_print):
1158 # Simulate the unit is being deployed on an already started machine.
1159 # This happens, e.g., when running Quickstart on a non-local
1160 # environment type: the unit is deployed on the bootstrap node, which
1161 # is assumed to be started.
1162 env = self.make_env([
1163 ([self.change_unit_pending], [self.change_machine_started]),
1164 ([self.change_unit_reachable], []),
1165 ([self.change_unit_installed], []),
1166 ([self.change_unit_started], []),
1167 ])
1168 address = app.watch(env, 'django/42')
1169 self.assertEqual(self.address, address)
1170 self.assertEqual(5, mock_print.call_count)
1171 mock_print.assert_has_calls([
1172 self.unit_pending_call,
1173 self.machine_started_call,
1174 self.unit_reachable_call,
1175 self.unit_installed_call,
1176 self.unit_started_call,
1093 ])1177 ])
10941178
1095 def test_extraneous_changes(self, mock_print):1179 def test_extraneous_changes(self, mock_print):
1096 # Extraneous changes are properly handled.1180 # Changes to units or machines we are not observing are ignored. Also
1181 # ensure that repeated changes to a single entity are ignored, even if
1182 # they are unlikely to happen.
1183 change_another_machine_pending = ('change', {
1184 'Id': '42',
1185 'Status': 'pending',
1186 })
1187 change_another_machine_started = ('change', {
1188 'Id': '1',
1189 'Status': 'started',
1190 })
1191 change_another_unit_pending = ('change', {
1192 'MachineId': '0',
1193 'Name': 'haproxy/0',
1194 'Status': 'pending',
1195 })
1196 change_another_unit_started = ('change', {
1197 'MachineId': '0',
1198 'Name': 'haproxy/0',
1199 'Status': 'started',
1200 })
1097 env = self.make_env([1201 env = self.make_env([
1098 self.change_pending,1202 # Add a repeated change.
1099 ('unit', 'change', {1203 ([self.change_unit_pending, self.change_unit_pending],
1100 'Name': 'django/42',1204 [self.change_machine_pending]),
1101 'Status': 'pending',1205 # Add extraneous unit and machine changes.
1102 'PublicAddress': '',1206 ([change_another_unit_pending], [change_another_machine_pending]),
1103 'Series': 'precise',1207 # Add a change to an extraneous machine.
1104 }),1208 ([], [change_another_machine_started,
1105 self.change_reachable,1209 self.change_machine_started]),
1106 self.change_installed,1210 # Add a change to an extraneous unit.
1107 ('unit', 'change', {1211 ([change_another_unit_started, self.change_unit_reachable], []),
1108 'Name': 'django/42',1212 ([self.change_unit_installed], []),
1109 'Status': 'installed',1213 # Add another repeated change.
1110 'PublicAddress': self.address,1214 ([self.change_unit_started, self.change_unit_started], []),
1111 'Series': 'precise',
1112 'MachineId': '3',
1113 }),
1114 self.change_started,
1115 ])1215 ])
1116 address = app.watch(env, 'django/42')1216 address = app.watch(env, 'django/42')
1117 self.assertEqual(self.address, address)1217 self.assertEqual(self.address, address)
1218 self.assertEqual(6, mock_print.call_count)
1118 mock_print.assert_has_calls([1219 mock_print.assert_has_calls([
1119 self.pending_call,1220 self.unit_pending_call,
1120 self.reachable_call,1221 self.machine_pending_call,
1121 self.installed_call,1222 self.machine_started_call,
1122 self.started_call,1223 self.unit_reachable_call,
1224 self.unit_installed_call,
1225 self.unit_started_call,
1123 ])1226 ])
11241227
1125 def test_api_error(self, mock_print):1228 def test_api_error(self, mock_print):
1126 # A ProgramExit is raised if an error occurs in one of the API calls.1229 # A ProgramExit is raised if an error occurs in one of the API calls.
1127 env = self.make_env([1230 env = self.make_env([
1128 self.change_pending,1231 ([self.change_unit_pending], []),
1129 self.make_env_error('next returned an error'),1232 self.make_env_error('next returned an error'),
1130 ])1233 ])
1131 expected = 'bad API server response: next returned an error'1234 expected = 'bad API server response: next returned an error'
1132 with self.assert_program_exit(expected):1235 with self.assert_program_exit(expected):
1133 app.watch(env, 'django/42')1236 app.watch(env, 'django/42')
1134 mock_print.assert_has_calls([self.pending_call])1237 self.assertEqual(1, mock_print.call_count)
1238 mock_print.assert_has_calls([self.unit_pending_call])
11351239
1136 def test_other_errors(self, mock_print):1240 def test_other_errors(self, mock_print):
1137 # Any other errors occurred during the process are not trapped.1241 # Any other errors occurred during the process are not trapped.
1138 error = ValueError('explode!')1242 error = ValueError('explode!')
1139 env = self.make_env([self.change_installed, error])1243 env = self.make_env([([self.change_unit_installed], []), error])
1140 with self.assertRaises(ValueError) as context_manager:1244 with self.assert_value_error('explode!'):
1141 app.watch(env, 'django/42')1245 app.watch(env, 'django/42')
1142 mock_print.assert_has_calls([self.reachable_call, self.installed_call])1246 self.assertEqual(2, mock_print.call_count)
1143 self.assertIs(error, context_manager.exception)1247 mock_print.assert_has_calls([
1248 self.unit_reachable_call, self.unit_installed_call])
11441249
1145 def test_unit_removed(self, mock_print):1250 def test_machine_status_error(self, mock_print):
1146 # A ProgramExit is raised if Juju removed the unit.1251 # A ProgramExit is raised if an the machine is found in an error state.
1147 env = self.make_env([1252 change_machine_error = ('change', {
1148 self.change_pending,1253 'Id': '0',
1149 ('unit', 'remove', {1254 'Status': 'error',
1150 'Name': 'django/42',1255 'StatusInfo': 'oddities',
1151 'Status': 'pending',1256 })
1152 'PublicAddress': '',1257 # The unit pending change is required to make the function know which
1153 }),1258 # machine to observe.
1154 self.change_reachable,1259 env = self.make_env([(
1260 [self.change_unit_pending], [change_machine_error]),
1155 ])1261 ])
1156 with self.assert_program_exit('django/42 unexpectedly removed'):1262 expected = 'machine 0 is in an error state: error: oddities'
1263 with self.assert_program_exit(expected):
1157 app.watch(env, 'django/42')1264 app.watch(env, 'django/42')
1158 mock_print.assert_has_calls([self.pending_call])1265 self.assertEqual(1, mock_print.call_count)
1266 mock_print.assert_has_calls([self.unit_pending_call])
11591267
1160 def test_status_error(self, mock_print):1268 def test_unit_status_error(self, mock_print):
1161 # A ProgramExit is raised if an the unit is found in an error state.1269 # A ProgramExit is raised if an the unit is found in an error state.
1162 env = self.make_env([1270 change_unit_error = ('change', {
1163 self.change_pending,1271 'MachineId': '0',
1164 ('unit', 'change', {1272 'Name': 'django/42',
1165 'Name': 'django/42',1273 'Status': 'error',
1166 'Status': 'error',1274 'StatusInfo': 'install failure',
1167 'StatusInfo': 'install failure',1275 })
1168 'PublicAddress': '',1276 env = self.make_env([([change_unit_error], [])])
1169 }),
1170 self.change_reachable,
1171 ])
1172 expected = 'django/42 is in an error state: error: install failure'1277 expected = 'django/42 is in an error state: error: install failure'
1173 with self.assert_program_exit(expected):1278 with self.assert_program_exit(expected):
1174 app.watch(env, 'django/42')1279 app.watch(env, 'django/42')
1175 mock_print.assert_has_calls([self.pending_call])1280 self.assertFalse(mock_print.called)
11761281
11771282
1178class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase):1283class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase):
11791284
=== modified file 'quickstart/tests/test_utils.py'
--- quickstart/tests/test_utils.py 2014-01-13 16:51:35 +0000
+++ quickstart/tests/test_utils.py 2014-01-17 18:31:35 +0000
@@ -620,54 +620,6 @@
620 self.assertEqual(list.append.__doc__, self.func.__doc__)620 self.assertEqual(list.append.__doc__, self.func.__doc__)
621621
622622
623class TestUnitChanges(unittest.TestCase):
624
625 unit = 'django/42'
626
627 def test_change_found(self):
628 # A relevant change is correctly found and returned.
629 change = ('unit', 'change', {'Name': self.unit, 'Status': 'pending'})
630 self.assertEqual(change, utils.unit_changes(self.unit, [change]))
631
632 def test_change_found_removed(self):
633 # A unit removal is considered a relevant change.
634 change = ('unit', 'remove', {'Name': self.unit, 'Status': 'started'})
635 self.assertEqual(change, utils.unit_changes(self.unit, [change]))
636
637 def test_not_a_unit(self):
638 # Changes to other entities are ignored.
639 change = ('service', 'change', {'Name': 'django', 'Exposed': True})
640 self.assertIsNone(utils.unit_changes(self.unit, [change]))
641
642 def test_not_a_specific_unit(self):
643 # Changes to other units are ignored.
644 change = ('unit', 'change', {'Name': 'django/0', 'Status': 'pending'})
645 self.assertIsNone(utils.unit_changes(self.unit, [change]))
646
647 def test_multiple_changes(self):
648 # A relevant change is found between multiple ones.
649 change = ('unit', 'change', {'Name': self.unit, 'Status': 'pending'})
650 changeset = [
651 ('machine', 'change', {'Id': '0', 'Status': 'started'}),
652 change,
653 ('service', 'change', {'Name': 'django', 'Exposed': True}),
654 ]
655 self.assertEqual(change, utils.unit_changes(self.unit, changeset))
656
657 def test_multiple_unit_changes(self):
658 # A changeset is not supposed to include multiple change for a single
659 # unit. The function just picks the first change.
660 data = {'Name': self.unit, 'Status': 'pending'}
661 changeset = [
662 ('unit', 'change', data),
663 ('unit', 'remove', data),
664 ('unit', 'exterminated', data),
665 ]
666 self.assertEqual(
667 ('unit', 'change', data),
668 utils.unit_changes(self.unit, changeset))
669
670
671class TestUrlread(unittest.TestCase):623class TestUrlread(unittest.TestCase):
672624
673 def patch_urlopen(self, contents=None, error=None, content_type=None):625 def patch_urlopen(self, contents=None, error=None, content_type=None):
674626
=== added file 'quickstart/tests/test_watchers.py'
--- quickstart/tests/test_watchers.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/test_watchers.py 2014-01-17 18:31:35 +0000
@@ -0,0 +1,266 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2014 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the Juju Quickstart environments watching utilities."""
18
19from __future__ import unicode_literals
20
21import unittest
22
23import mock
24
25from quickstart import watchers
26from quickstart.tests import helpers
27
28
29class TestParseMachineChange(helpers.ValueErrorTestsMixin, unittest.TestCase):
30
31 def test_machine_removed(self):
32 # A ValueError is raised if the change represents a machine removal.
33 data = {'Id': '1', 'Status': 'started'}
34 with self.assert_value_error('machine 1 unexpectedly removed'):
35 watchers.parse_machine_change('remove', data, '')
36
37 def test_machine_error(self):
38 # A ValueError is raised if the machine is in an error state.
39 data = {'Id': '1', 'Status': 'error', 'StatusInfo': 'bad wolf'}
40 expected_error = 'machine 1 is in an error state: error: bad wolf'
41 with self.assert_value_error(expected_error):
42 watchers.parse_machine_change('change', data, '')
43
44 @helpers.mock_print
45 def test_pending_status_notified(self, mock_print):
46 # A message is printed to stdout when the machine changes its status
47 # to "pending". The new status is also returned by the function.
48 data = {'Id': '1', 'Status': 'pending'}
49 status = watchers.parse_machine_change('change', data, '')
50 self.assertEqual('pending', status)
51 mock_print.assert_called_once_with('machine 1 provisioning is pending')
52
53 @helpers.mock_print
54 def test_started_status_notified(self, mock_print):
55 # A message is printed to stdout when the machine changes its status
56 # to "started". The new status is also returned by the function.
57 data = {'Id': '42', 'Status': 'started'}
58 status = watchers.parse_machine_change('change', data, 'pending')
59 self.assertEqual('started', status)
60 mock_print.assert_called_once_with('machine 42 is started')
61
62 @helpers.mock_print
63 def test_status_not_changed(self, mock_print):
64 # If the status in the machine change and the given current status are
65 # the same value, nothing is printed and the status is returned.
66 data = {'Id': '47', 'Status': 'pending'}
67 status = watchers.parse_machine_change('change', data, 'pending')
68 self.assertEqual('pending', status)
69 self.assertFalse(mock_print.called)
70
71
72class TestParseUnitChange(helpers.ValueErrorTestsMixin, unittest.TestCase):
73
74 def test_unit_removed(self):
75 # A ValueError is raised if the change represents a unit removal.
76 data = {'Name': 'django/42', 'Status': 'started'}
77 with self.assert_value_error('django/42 unexpectedly removed'):
78 # The last two arguments are the current status and address.
79 watchers.parse_unit_change('remove', data, '', '')
80
81 def test_unit_error(self):
82 # A ValueError is raised if the unit is in an error state.
83 data = {
84 'Name': 'django/0',
85 'Status': 'start error',
86 'StatusInfo': 'bad wolf',
87 }
88 expected_error = 'django/0 is in an error state: start error: bad wolf'
89 with self.assert_value_error(expected_error):
90 # The last two arguments are the current status and address.
91 watchers.parse_unit_change('change', data, '', '')
92
93 @helpers.mock_print
94 def test_address_notified(self, mock_print):
95 # A message is printed to stdout when the unit obtains a public
96 # address. The function returns the status, the new address and the
97 # machine identifier.
98 data = {
99 'Name': 'haproxy/2',
100 'Status': 'pending',
101 'PublicAddress': 'haproxy2.example.com',
102 'MachineId': '42',
103 }
104 status, address, machine_id = watchers.parse_unit_change(
105 'change', data, 'pending', '')
106 self.assertEqual('pending', status)
107 self.assertEqual('haproxy2.example.com', address)
108 self.assertEqual('42', machine_id)
109 mock_print.assert_called_once_with(
110 'setting up haproxy/2 on haproxy2.example.com')
111
112 @helpers.mock_print
113 def test_pending_status_notified(self, mock_print):
114 # A message is printed to stdout when the unit changes its status to
115 # "pending". The function returns the new status, the address and the
116 # machine identifier. The last two values are empty strings if the unit
117 # has not yet been assigned to a machine.
118 data = {'Name': 'django/1', 'Status': 'pending', 'PublicAddress': ''}
119 # The last two arguments are the current status and address.
120 status, address, machine_id = watchers.parse_unit_change(
121 'change', data, '', '')
122 self.assertEqual('pending', status)
123 self.assertEqual('', address)
124 self.assertEqual('', machine_id)
125 mock_print.assert_called_once_with('django/1 deployment is pending')
126
127 @helpers.mock_print
128 def test_installed_status_notified(self, mock_print):
129 # A message is printed to stdout when the unit changes its status to
130 # "installed". The function returns the new status, the address and the
131 # machine identifier.
132 data = {
133 'Name': 'django/42',
134 'Status': 'installed',
135 'PublicAddress': 'django42.example.com',
136 'MachineId': '1',
137 }
138 status, address, machine_id = watchers.parse_unit_change(
139 'change', data, 'pending', 'django42.example.com')
140 self.assertEqual('installed', status)
141 self.assertEqual('django42.example.com', address)
142 self.assertEqual('1', machine_id)
143 mock_print.assert_called_once_with('django/42 is installed')
144
145 @helpers.mock_print
146 def test_started_status_notified(self, mock_print):
147 # A message is printed to stdout when the unit changes its status to
148 # "started". The function returns the new status, the address and the
149 # machine identifier.
150 data = {
151 'Name': 'wordpress/0',
152 'Status': 'started',
153 'PublicAddress': 'wordpress0.example.com',
154 'MachineId': '0',
155 }
156 status, address, machine_id = watchers.parse_unit_change(
157 'change', data, '', 'wordpress0.example.com')
158 self.assertEqual('started', status)
159 self.assertEqual('wordpress0.example.com', address)
160 self.assertEqual('0', machine_id)
161 mock_print.assert_called_once_with('wordpress/0 is ready on machine 0')
162
163 @helpers.mock_print
164 def test_both_status_and_address_notified(self, mock_print):
165 # Both status and public address changes are notified if required.
166 data = {
167 'Name': 'django/0',
168 'Status': 'started',
169 'PublicAddress': 'django42.example.com',
170 'MachineId': '0',
171 }
172 # The last two arguments are the current status and address.
173 watchers.parse_unit_change('change', data, '', '')
174 self.assertEqual(2, mock_print.call_count)
175 mock_print.assert_has_calls([
176 mock.call('setting up django/0 on django42.example.com'),
177 mock.call('django/0 is ready on machine 0'),
178 ])
179
180 @helpers.mock_print
181 def test_status_not_changed(self, mock_print):
182 # If the status in the unit change and the given current status are the
183 # same value, nothing is printed and the current values are returned.
184 data = {'Name': 'django/1', 'Status': 'pending', 'PublicAddress': ''}
185 status, address, machine_id = watchers.parse_unit_change(
186 'change', data, 'pending', '')
187 self.assertEqual('pending', status)
188 self.assertEqual('', address)
189 self.assertEqual('', machine_id)
190 self.assertFalse(mock_print.called)
191
192
193class TestUnitMachineChanges(unittest.TestCase):
194
195 def test_unit_changes_found(self):
196 # Unit changes are correctly found and returned.
197 data1 = {'Name': 'django/42', 'Status': 'started'}
198 data2 = {'Name': 'django/47', 'Status': 'pending'}
199 changeset = [('unit', 'change', data1), ('unit', 'remove', data2)]
200 expected_unit_changes = [('change', data1), ('remove', data2)]
201 self.assertEqual(
202 (expected_unit_changes, []),
203 watchers.unit_machine_changes(changeset))
204
205 def test_machine_changes_found(self):
206 # Machine changes are correctly found and returned.
207 data1 = {'Id': '0', 'Status': 'started'}
208 data2 = {'Id': '1', 'Status': 'error'}
209 changeset = [
210 ('machine', 'change', data1),
211 ('machine', 'remove', data2),
212 ]
213 expected_machine_changes = [('change', data1), ('remove', data2)]
214 self.assertEqual(
215 ([], expected_machine_changes),
216 watchers.unit_machine_changes(changeset))
217
218 def test_unit_and_machine_changes_found(self):
219 # Changes to unit and machines are reordered, grouped and returned.
220 machine_data1 = {'Id': '0', 'Status': 'started'}
221 machine_data2 = {'Id': '42', 'Status': 'started'}
222 unit_data1 = {'Name': 'django/42', 'Status': 'error'}
223 unit_data2 = {'Name': 'haproxy/47', 'Status': 'pending'}
224 unit_data3 = {'Name': 'wordpress/0', 'Status': 'installed'}
225 changeset = [
226 ('machine', 'change', machine_data1),
227 ('unit', 'change', unit_data1),
228 ('machine', 'remove', machine_data2),
229 ('unit', 'change', unit_data2),
230 ('unit', 'remove', unit_data3),
231 ]
232 expected_unit_changes = [
233 ('change', unit_data1),
234 ('change', unit_data2),
235 ('remove', unit_data3),
236 ]
237 expected_machine_changes = [
238 ('change', machine_data1),
239 ('remove', machine_data2),
240 ]
241 self.assertEqual(
242 (expected_unit_changes, expected_machine_changes),
243 watchers.unit_machine_changes(changeset))
244
245 def test_other_entities(self):
246 # Changes to other entities (like services) are ignored.
247 machine_data = {'Id': '0', 'Status': 'started'}
248 unit_data = {'Name': 'django/42', 'Status': 'error'}
249 changeset = [
250 ('machine', 'change', machine_data),
251 ('service', 'change', {'Name': 'django', 'Status': 'pending'}),
252 ('unit', 'change', unit_data),
253 ('service', 'remove', {'Name': 'haproxy', 'Status': 'started'}),
254 ]
255 expected_changes = (
256 [('change', unit_data)],
257 [('change', machine_data)],
258 )
259 self.assertEqual(
260 expected_changes, watchers.unit_machine_changes(changeset))
261
262 def test_empty_changeset(self):
263 # Two empty lists are returned if the changeset is empty.
264 # This should never occur in the real world, but it's tested here to
265 # demonstrate this function behavior.
266 self.assertEqual(([], []), watchers.unit_machine_changes([]))
0267
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2014-01-10 15:30:49 +0000
+++ quickstart/utils.py 2014-01-17 18:31:35 +0000
@@ -341,18 +341,6 @@
341 return decorated341 return decorated
342342
343343
344def unit_changes(unit_name, changeset):
345 """Parse the changeset and return the change related to the given unit.
346
347 This function is intended to be used as a processor callable for the
348 watch_changes method of quickstart.juju.Environment.
349 """
350 for change in changeset:
351 entity, _, data = change
352 if entity == 'unit' and data['Name'] == unit_name:
353 return change
354
355
356def urlread(url):344def urlread(url):
357 """Open the given URL and return the page contents.345 """Open the given URL and return the page contents.
358346
359347
=== added file 'quickstart/watchers.py'
--- quickstart/watchers.py 1970-01-01 00:00:00 +0000
+++ quickstart/watchers.py 2014-01-17 18:31:35 +0000
@@ -0,0 +1,118 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2014 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Juju Quickstart utilities for watching Juju environments."""
18
19from __future__ import (
20 print_function,
21 unicode_literals,
22)
23
24
25def parse_machine_change(action, data, current_status):
26 """Parse the given machine change.
27
28 The change is represented by the given action/data pair.
29 Also receive the last known machine status, which can be an empty string
30 if the information is unknown.
31
32 Output a human readable message each time a relevant change is found.
33
34 Return the machine status.
35 Raise a ValueError if the machine is removed or in an error state.
36 """
37 machine_id = data['Id']
38 status = data['Status']
39 # Exit with an error if the machine is removed.
40 if action == 'remove':
41 msg = 'machine {} unexpectedly removed'.format(machine_id)
42 raise ValueError(msg.encode('utf-8'))
43 if 'error' in status:
44 msg = 'machine {} is in an error state: {}: {}'.format(
45 machine_id, status, data['StatusInfo'])
46 raise ValueError(msg.encode('utf-8'))
47 # Notify status changes.
48 if status != current_status:
49 if status == 'pending':
50 print('machine {} provisioning is pending'.format(
51 machine_id))
52 elif status == 'started':
53 print('machine {} is started'.format(machine_id))
54 return status
55
56
57def parse_unit_change(action, data, current_status, address):
58 """Parse the given unit change.
59
60 The change is represented by the given action/data pair.
61 Also receive the last known unit status and address, which can be empty
62 strings if those pieces of information are unknown.
63
64 Output a human readable message each time a relevant change is found.
65
66 Return the unit status, address and machine identifier.
67 Raise a ValueError if the service unit is removed or in an error state.
68 """
69 unit_name = data['Name']
70 # Exit with an error if the unit is removed.
71 if action == 'remove':
72 msg = '{} unexpectedly removed'.format(unit_name)
73 raise ValueError(msg.encode('utf-8'))
74 # Exit with an error if the unit is in an error state.
75 status = data['Status']
76 if 'error' in status:
77 msg = '{} is in an error state: {}: {}'.format(
78 unit_name, status, data['StatusInfo'])
79 raise ValueError(msg.encode('utf-8'))
80 # Notify when the unit becomes reachable.
81 if not address:
82 address = data['PublicAddress']
83 if address:
84 print('setting up {} on {}'.format(unit_name, address))
85 # Notify status changes.
86 if status != current_status:
87 if status == 'pending':
88 print('{} deployment is pending'.format(unit_name))
89 elif status == 'installed':
90 print('{} is installed'.format(unit_name))
91 elif address and status == 'started':
92 print('{} is ready on machine {}'.format(
93 unit_name, data['MachineId']))
94 return status, address, data.get('MachineId', '')
95
96
97def unit_machine_changes(changeset):
98 """Parse the changeset and return the units and machines related changes.
99
100 Changes to units and machines are grouped into two lists, e.g.:
101
102 unit_changes, machine_changes = unit_machine_changes(changeset)
103
104 Each list includes (action, data) tuples, in which:
105 - action is he change type (e.g. "change", "remove");
106 - data is the actual information about the changed entity (as a dict).
107
108 This function is intended to be used as a processor callable for the
109 watch_changes method of quickstart.juju.Environment.
110 """
111 unit_changes = []
112 machine_changes = []
113 for entity, action, data in changeset:
114 if entity == 'unit':
115 unit_changes.append((action, data))
116 elif entity == 'machine':
117 machine_changes.append((action, data))
118 return unit_changes, machine_changes

Subscribers

People subscribed via source and target branches