Merge lp:~frankban/juju-quickstart/maas-address into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 103
Proposed branch: lp:~frankban/juju-quickstart/maas-address
Merge into: lp:juju-quickstart
Diff against target: 1288 lines (+332/-433)
12 files modified
README.rst (+2/-0)
quickstart/__init__.py (+1/-1)
quickstart/app.py (+21/-31)
quickstart/manage.py (+6/-11)
quickstart/settings.py (+3/-0)
quickstart/tests/helpers.py (+11/-0)
quickstart/tests/test_app.py (+63/-183)
quickstart/tests/test_manage.py (+19/-68)
quickstart/tests/test_utils.py (+18/-0)
quickstart/tests/test_watchers.py (+122/-108)
quickstart/utils.py (+14/-0)
quickstart/watchers.py (+52/-31)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/maas-address
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+241263@code.launchpad.net

Description of the change

Unit address from the machines watcher only

Only use the mega-watcher for machines to retrieve
the Juju GUI unit address.
This change has several consequences:
- it allows us to apply some logic on how the
  right address is chosen. For instance, now
  we try to resolve public hostnames before
  proceeding, and this should fix the cases
  where a cloud dns is not configured on the
  machine running quickstart. This is the case
  of many maas environments;
- it simplifies parsing the mega-watcher changes;
- more importantly, it breaks compatibility
  with very old versions of juju (<1.18), in which
  the mega-watcher for machines did not include
  machine addresses.

For this reason, quickstart now explicitly
drops support for juju < 1.18.1
(1.18.1 is the version on trusty universe).

This also allows for removing some version
checks in the code, including sudo handling when
calling bootstrap on local envs, several special
cases on the watcher side, and other oddities.

For the reasons above, I bumped the quickstart
version up to 1.5.0.

PS: my apologies for the long diff, hope the code
is still easy to follow. Sorry.

Tests: `make check`

QA:
run quickstart as usual, on local and cloud envs,
check it works properly when run again, etc.
this branch has been already successfully QAed in
a maas environment by Adam (Landscape team).

https://codereview.appspot.com/174790043/

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

Reviewers: mp+241263_code.launchpad.net,

Message:
Please take a look.

Description:
Unit address from the machines watcher only

Only use the mega-watcher for machines to retrieve
the Juju GUI unit address.
This change has several consequences:
- it allows us to apply some logic on how the
   right address is chosen. For instance, now
   we try to resolve public hostnames before
   proceeding, and this should fix the cases
   where a cloud dns is not configured on the
   machine running quickstart. This is the case
   of many maas environments;
- it simplifies parsing the mega-watcher changes;
- more importantly, it breaks compatibility
   with very old versions of juju (<1.18), in which
   the mega-watcher for machines did not include
   machine addresses.

For this reason, quickstart now explicitly
drops support for juju < 1.18.1
(1.18.1 is the version on trusty universe).

This also allows for removing some version
checks in the code, including sudo handling when
calling bootstrap on local envs, several special
cases on the watcher side, and other oddities.

For the reasons above, I bumped the quickstart
version up to 1.5.0.

PS: my apologies for the long diff, hope the code
is still easy to follow. Sorry.

Tests: `make check`

QA:
run quickstart as usual, on local and cloud envs,
check it works properly when run again, etc.
this branch has been already successfully QAed in
a maas environment by Adam (Landscape team).

https://code.launchpad.net/~frankban/juju-quickstart/maas-address/+merge/241263

(do not edit description out of merge proposal)

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

Affected files (+334, -433 lines):
   M README.rst
   A [revision details]
   M quickstart/__init__.py
   M quickstart/app.py
   M quickstart/manage.py
   M quickstart/settings.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/tests/test_watchers.py
   M quickstart/utils.py
   M quickstart/watchers.py

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

On 2014/11/10 12:30:34, frankban wrote:
> Please take a look.

LGTM. Have not done QA yet.

https://codereview.appspot.com/174790043/

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

LGTM ty for the updates!

https://codereview.appspot.com/174790043/diff/1/quickstart/app.py
File quickstart/app.py (left):

https://codereview.appspot.com/174790043/diff/1/quickstart/app.py#oldcode188
quickstart/app.py:188: if requires_sudo:
is this because of the new juju requirement?

https://codereview.appspot.com/174790043/

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

Thank you both for the reviews of this long diff!

https://codereview.appspot.com/174790043/diff/1/quickstart/app.py
File quickstart/app.py (left):

https://codereview.appspot.com/174790043/diff/1/quickstart/app.py#oldcode188
quickstart/app.py:188: if requires_sudo:
On 2014/11/10 15:25:20, rharding wrote:
> is this because of the new juju requirement?

Yes! We got rid of very old versions of juju requiring an explicit sudo
on the command line.

https://codereview.appspot.com/174790043/

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

*** Submitted:

Unit address from the machines watcher only

Only use the mega-watcher for machines to retrieve
the Juju GUI unit address.
This change has several consequences:
- it allows us to apply some logic on how the
   right address is chosen. For instance, now
   we try to resolve public hostnames before
   proceeding, and this should fix the cases
   where a cloud dns is not configured on the
   machine running quickstart. This is the case
   of many maas environments;
- it simplifies parsing the mega-watcher changes;
- more importantly, it breaks compatibility
   with very old versions of juju (<1.18), in which
   the mega-watcher for machines did not include
   machine addresses.

For this reason, quickstart now explicitly
drops support for juju < 1.18.1
(1.18.1 is the version on trusty universe).

This also allows for removing some version
checks in the code, including sudo handling when
calling bootstrap on local envs, several special
cases on the watcher side, and other oddities.

For the reasons above, I bumped the quickstart
version up to 1.5.0.

PS: my apologies for the long diff, hope the code
is still easy to follow. Sorry.

Tests: `make check`

QA:
run quickstart as usual, on local and cloud envs,
check it works properly when run again, etc.
this branch has been already successfully QAed in
a maas environment by Adam (Landscape team).

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

https://codereview.appspot.com/174790043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'README.rst'
--- README.rst 2014-09-01 14:44:39 +0000
+++ README.rst 2014-11-10 12:30:00 +0000
@@ -36,6 +36,8 @@
36Juju Quickstart is available on Ubuntu releases 12.04 LTS (precise), 14.04 LTS36Juju Quickstart is available on Ubuntu releases 12.04 LTS (precise), 14.04 LTS
37(trusty), 14.10 (utopic) and on OS X (10.7 and later).37(trusty), 14.10 (utopic) and on OS X (10.7 and later).
3838
39Starting from version 1.5.0, Juju Quickstart only supports Juju >= 1.18.1.
40
39Ubuntu Installation41Ubuntu Installation
40~~~~~~~~~~~~~~~~~~~42~~~~~~~~~~~~~~~~~~~
4143
4244
=== modified file 'quickstart/__init__.py'
--- quickstart/__init__.py 2014-10-01 13:43:17 +0000
+++ quickstart/__init__.py 2014-11-10 12:30:00 +0000
@@ -45,7 +45,7 @@
45Once Juju has been installed, the command can also be run as a juju plugin,45Once Juju has been installed, the command can also be run as a juju plugin,
46without the hyphen ("juju quickstart").46without the hyphen ("juju quickstart").
47"""47"""
48VERSION = (1, 4, 4)48VERSION = (1, 5, 0)
4949
5050
51def get_version():51def get_version():
5252
=== modified file 'quickstart/app.py'
--- quickstart/app.py 2014-08-25 16:57:41 +0000
+++ quickstart/app.py 2014-11-10 12:30:00 +0000
@@ -116,6 +116,19 @@
116 return juju_version116 return juju_version
117117
118118
119def check_juju_supported(juju_version):
120 """Ensure the given Juju version is supported by Quickstart.
121
122 Raise a ProgramExit if Juju is not supported.
123 """
124 if juju_version < settings.JUJU_SUPPORTED_VERSION:
125 current = b'.'.join(map(bytes, juju_version))
126 supported = b'.'.join(map(bytes, settings.JUJU_SUPPORTED_VERSION))
127 raise ProgramExit(
128 b'the current Juju version ({}) is not supported: '
129 b'please upgrade to Juju >= {}'.format(current, supported))
130
131
119def ensure_ssh_keys():132def ensure_ssh_keys():
120 """Ensure that SSH keys are available.133 """Ensure that SSH keys are available.
121134
@@ -163,8 +176,9 @@
163 raise ProgramExit(bytes(err))176 raise ProgramExit(bytes(err))
164177
165178
166def bootstrap(env_name, juju_command, requires_sudo=False, debug=False,179def bootstrap(
167 upload_tools=False, upload_series=None, constraints=None):180 env_name, juju_command, debug=False, upload_tools=False,
181 upload_series=None, constraints=None):
168 """Bootstrap the Juju environment with the given name.182 """Bootstrap the Juju environment with the given name.
169183
170 Do not bootstrap the environment if already bootstrapped.184 Do not bootstrap the environment if already bootstrapped.
@@ -185,8 +199,6 @@
185 """199 """
186 already_bootstrapped = False200 already_bootstrapped = False
187 cmd = [juju_command, 'bootstrap', '-e', env_name]201 cmd = [juju_command, 'bootstrap', '-e', env_name]
188 if requires_sudo:
189 cmd.insert(0, 'sudo')
190 if debug:202 if debug:
191 cmd.append('--debug')203 cmd.append('--debug')
192 if upload_tools:204 if upload_tools:
@@ -497,14 +509,10 @@
497 for action, data in unit_changes:509 for action, data in unit_changes:
498 if data['Name'] == unit_name:510 if data['Name'] == unit_name:
499 try:511 try:
500 data = watchers.parse_unit_change(512 unit_status, machine_id = watchers.parse_unit_change(
501 action, data, unit_status, address)513 action, data, unit_status)
502 except ValueError as err:514 except ValueError as err:
503 raise ProgramExit(bytes(err))515 raise ProgramExit(bytes(err))
504 unit_status, address, machine_id = data
505 if address and unit_status == 'started':
506 # We can exit this loop.
507 return address
508 # The mega-watcher contains a single change for each specific516 # The mega-watcher contains a single change for each specific
509 # unit. For this reason, we can exit the for loop here.517 # unit. For this reason, we can exit the for loop here.
510 break518 break
@@ -528,12 +536,12 @@
528 action, data, machine_status, address)536 action, data, machine_status, address)
529 except ValueError as err:537 except ValueError as err:
530 raise ProgramExit(bytes(err))538 raise ProgramExit(bytes(err))
531 if address and unit_status == 'started':
532 # We can exit this loop.
533 return address
534 # The mega-watcher contains a single change for each specific539 # The mega-watcher contains a single change for each specific
535 # machine. For this reason, we can exit the for loop here.540 # machine. For this reason, we can exit the for loop here.
536 break541 break
542 if address and unit_status == 'started':
543 # We can exit this loop.
544 return address
537545
538546
539def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id):547def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id):
@@ -549,21 +557,3 @@
549 env.deploy_bundle(bundle_yaml, name=bundle_name, bundle_id=bundle_id)557 env.deploy_bundle(bundle_yaml, name=bundle_name, bundle_id=bundle_id)
550 except jujuclient.EnvError as err:558 except jujuclient.EnvError as err:
551 raise ProgramExit('bad API server response: {}'.format(err.message))559 raise ProgramExit('bad API server response: {}'.format(err.message))
552
553
554def juju_requires_sudo(env_type, juju_version, customized):
555 """Report whether the given Juju version requires "sudo".
556
557 The env_type argument is the current environment type.
558 "e.g. local, ec2, azure"
559
560 Raise a ProgramExit if a customized version of Juju is being used and Juju
561 itself requires "sudo"
562 """
563 # If the Juju version is less than 1.17.2 then use sudo for local envs.
564 if env_type == 'local' and juju_version < (1, 17, 2):
565 if customized:
566 raise ProgramExit(b'cannot use a customized Juju when it '
567 'requires sudo')
568 return True
569 return False
570560
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2014-08-25 16:57:41 +0000
+++ quickstart/manage.py 2014-11-10 12:30:00 +0000
@@ -502,9 +502,7 @@
502 logging.debug('ensuring juju and dependencies are installed')502 logging.debug('ensuring juju and dependencies are installed')
503 juju_version = app.ensure_dependencies(503 juju_version = app.ensure_dependencies(
504 options.distro_only, options.platform, juju_command)504 options.distro_only, options.platform, juju_command)
505505 app.check_juju_supported(juju_version)
506 requires_sudo = app.juju_requires_sudo(
507 options.env_type, juju_version, custom_juju)
508506
509 logging.debug('ensuring SSH keys are available')507 logging.debug('ensuring SSH keys are available')
510 app.ensure_ssh_keys()508 app.ensure_ssh_keys()
@@ -513,13 +511,12 @@
513 options.env_name, options.env_type))511 options.env_name, options.env_type))
514 if options.env_type == 'local':512 if options.env_type == 'local':
515 # If this is a local environment, notify the user that "sudo" will be513 # If this is a local environment, notify the user that "sudo" will be
516 # required to bootstrap the application, even in newer Juju versions514 # required by Juju to bootstrap the application.
517 # where "sudo" is invoked by juju-core itself.
518 print('sudo privileges will be required to bootstrap the environment')515 print('sudo privileges will be required to bootstrap the environment')
519516
520 already_bootstrapped, bootstrap_node_series = app.bootstrap(517 already_bootstrapped, bootstrap_node_series = app.bootstrap(
521 options.env_name, juju_command,518 options.env_name, juju_command,
522 requires_sudo=requires_sudo, debug=options.debug,519 debug=options.debug,
523 upload_tools=options.upload_tools,520 upload_tools=options.upload_tools,
524 upload_series=options.upload_series,521 upload_series=options.upload_series,
525 constraints=options.constraints)522 constraints=options.constraints)
@@ -585,9 +582,7 @@
585 'Run "juju quickstart -i" if you want to manage\n'582 'Run "juju quickstart -i" if you want to manage\n'
586 'or bootstrap your Juju environments using the\n'583 'or bootstrap your Juju environments using the\n'
587 'interactive session.\n'584 'interactive session.\n'
588 'Run "{sudo}juju destroy-environment {eflag}{env_name} [-y]"\n'585 'Run "juju destroy-environment {env_name} [-y]"\n'
589 'to destroy the environment you just bootstrapped.'.format(586 'to destroy the environment you just bootstrapped.'
590 env_name=options.env_name,587 ''.format(env_name=options.env_name)
591 sudo='sudo ' if requires_sudo else '',
592 eflag='-e ' if juju_version < (1, 17, 2) else '')
593 )588 )
594589
=== modified file 'quickstart/settings.py'
--- quickstart/settings.py 2014-10-09 13:14:30 +0000
+++ quickstart/settings.py 2014-11-10 12:30:00 +0000
@@ -76,6 +76,9 @@
76# The set of series supported by the Juju GUI charm.76# The set of series supported by the Juju GUI charm.
77JUJU_GUI_SUPPORTED_SERIES = tuple(DEFAULT_CHARM_URLS.keys())77JUJU_GUI_SUPPORTED_SERIES = tuple(DEFAULT_CHARM_URLS.keys())
7878
79# The minimum Juju version supported by Juju Quickstart,
80JUJU_SUPPORTED_VERSION = (1, 18, 1)
81
79# The path to the MAAS command line interface.82# The path to the MAAS command line interface.
80MAAS_CMD = '/usr/bin/maas'83MAAS_CMD = '/usr/bin/maas'
8184
8285
=== modified file 'quickstart/tests/helpers.py'
--- quickstart/tests/helpers.py 2014-09-08 17:49:51 +0000
+++ quickstart/tests/helpers.py 2014-11-10 12:30:00 +0000
@@ -195,6 +195,17 @@
195mock_print = mock.patch('__builtin__.print')195mock_print = mock.patch('__builtin__.print')
196196
197197
198def patch_check_resolvable(error=None):
199 """Patch the utils.check_resolvable function to return the given error.
200
201 This is done so that tests do not try to resolve hostname addresses.
202 """
203 return mock.patch(
204 'quickstart.utils.check_resolvable',
205 lambda hostname: error,
206 )
207
208
198class UrlReadTestsMixin(object):209class UrlReadTestsMixin(object):
199 """Expose a method to mock the quickstart.utils.urlread helper function."""210 """Expose a method to mock the quickstart.utils.urlread helper function."""
200211
201212
=== modified file 'quickstart/tests/test_app.py'
--- quickstart/tests/test_app.py 2014-08-25 16:57:41 +0000
+++ quickstart/tests/test_app.py 2014-11-10 12:30:00 +0000
@@ -298,6 +298,28 @@
298 self.assertEqual(3, mock_call.call_count)298 self.assertEqual(3, mock_call.call_count)
299299
300300
301class TestCheckJujuSupported(ProgramExitTestsMixin, unittest.TestCase):
302
303 supported_versions = [(1, 18, 1), (1, 19, 0), (1, 42, 47), (2, 0, 0)]
304 unsupported_versions = [(1, 18, 0), (1, 17, 42), (1, 0, 0), (0, 20, 47)]
305
306 def test_supported(self):
307 # No exceptions are raised if the Juju version is supported.
308 for version in self.supported_versions:
309 app.check_juju_supported(version)
310
311 def test_not_supported(self):
312 # A ProgramExit error is raised if the Juju version is not supported.
313 error = (
314 'the current Juju version ({}.{}.{}) is not supported: '
315 'please upgrade to Juju >= ' +
316 '.'.join(map(str, settings.JUJU_SUPPORTED_VERSION))
317 )
318 for version in self.unsupported_versions:
319 with self.assert_program_exit(error.format(*version)):
320 app.check_juju_supported(version)
321
322
301@helpers.mock_print323@helpers.mock_print
302class TestEnsureSSHKeys(324class TestEnsureSSHKeys(
303 helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):325 helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):
@@ -484,19 +506,6 @@
484 ] + self.make_status_calls(1))506 ] + self.make_status_calls(1))
485 mock_print.assert_called_once_with(self.status_message)507 mock_print.assert_called_once_with(self.status_message)
486508
487 def test_success_local_provider(self, mock_print):
488 # The environment is bootstrapped with sudo using the local provider.
489 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:
490 already_bootstrapped, series = app.bootstrap(
491 self.env_name, self.juju_command, requires_sudo=True)
492 self.assertFalse(already_bootstrapped)
493 self.assertEqual(series, 'hoary')
494 mock_call.assert_has_calls([
495 mock.call(
496 'sudo', self.juju_command, 'bootstrap', '-e', self.env_name),
497 ] + self.make_status_calls(1))
498 mock_print.assert_called_once_with(self.status_message)
499
500 def test_success_debug(self, mock_print):509 def test_success_debug(self, mock_print):
501 # The environment is successfully bootstrapped in debug mode.510 # The environment is successfully bootstrapped in debug mode.
502 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:511 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:
@@ -1275,16 +1284,16 @@
12751284
12761285
1277@helpers.mock_print1286@helpers.mock_print
1287@helpers.patch_check_resolvable()
1278class TestWatch(1288class TestWatch(
1279 ProgramExitTestsMixin, helpers.ValueErrorTestsMixin,1289 ProgramExitTestsMixin, helpers.ValueErrorTestsMixin,
1280 unittest.TestCase):1290 unittest.TestCase):
12811291
1282 address = 'unit.example.com'1292 address = 'unit.example.com'
1283 machine_pending_call = mock.call('machine 0 provisioning is pending')1293 machine_pending_call = mock.call('machine 0 provisioning is pending')
1284 unit_placed_machine_call = mock.call('unit placed on unit.example.com')1294 unit_placed_call = mock.call('unit placed on {}'.format(address))
1285 machine_started_call = mock.call('machine 0 is started')1295 machine_started_call = mock.call('machine 0 is started')
1286 unit_pending_call = mock.call('django/42 deployment is pending')1296 unit_pending_call = mock.call('django/42 deployment is pending')
1287 unit_placed_unit_call = mock.call('django/42 placed on {}'.format(address))
1288 unit_installed_call = mock.call('django/42 is installed')1297 unit_installed_call = mock.call('django/42 is installed')
1289 unit_started_call = mock.call('django/42 is ready on machine 0')1298 unit_started_call = mock.call('django/42 is ready on machine 0')
12901299
@@ -1314,16 +1323,9 @@
1314 }]1323 }]
1315 return 'change', data1324 return 'change', data
13161325
1317 def make_unit_change(self, status, name='django/42', address=None):1326 def make_unit_change(self, status, name='django/42'):
1318 """Create and return a unit change.1327 """Create and return a unit change."""
13191328 return 'change', {'MachineId': '0', 'Name': name, 'Status': status}
1320 If the address argument is None, the change does not include the
1321 corresponding address field.
1322 """
1323 data = {'MachineId': '0', 'Name': name, 'Status': status}
1324 if address is not None:
1325 data['PublicAddress'] = address
1326 return 'change', data
13271329
1328 # The following group of tests exercises both the function return value and1330 # The following group of tests exercises both the function return value and
1329 # the function output, even if the output is handled by sub-functions.1331 # the function output, even if the output is handled by sub-functions.
@@ -1334,35 +1336,10 @@
1334 # The glorious moments in the unit's life are properly highlighted.1336 # The glorious moments in the unit's life are properly highlighted.
1335 # The machine achievements are also celebrated.1337 # The machine achievements are also celebrated.
1336 env = self.make_env([1338 env = self.make_env([
1337 ([self.make_unit_change('pending', address='')],1339 ([self.make_unit_change('pending')],
1338 [self.make_machine_change('pending')]),1340 [self.make_machine_change('pending')]),
1339 ([], [self.make_machine_change('started')]),1341 ([], [self.make_machine_change('started')]),
1340 ([self.make_unit_change('pending', address=self.address)], []),
1341 ([self.make_unit_change('installed', address=self.address)], []),
1342 ([self.make_unit_change('started', address=self.address)], []),
1343 ])
1344 address = app.watch(env, 'django/42')
1345 self.assertEqual(self.address, address)
1346 self.assertEqual(6, mock_print.call_count)
1347 mock_print.assert_has_calls([
1348 self.unit_pending_call,
1349 self.machine_pending_call,
1350 self.machine_started_call,
1351 self.unit_placed_unit_call,
1352 self.unit_installed_call,
1353 self.unit_started_call,
1354 ])
1355
1356 def test_unit_life_with_machine_address(self, mock_print):
1357 # The glorious moments in the unit's life are properly highlighted.
1358 # The machine achievements are also celebrated.
1359 # This time the new mega-watcher behavior is simulated, in which
1360 # addresses are included in the machine change.
1361 env = self.make_env([
1362 ([self.make_unit_change('pending')],
1363 [self.make_machine_change('pending', address='')]),
1364 ([], [self.make_machine_change('started', address=self.address)]),1342 ([], [self.make_machine_change('started', address=self.address)]),
1365 ([self.make_unit_change('pending')], []),
1366 ([self.make_unit_change('installed')], []),1343 ([self.make_unit_change('installed')], []),
1367 ([self.make_unit_change('started')], []),1344 ([self.make_unit_change('started')], []),
1368 ])1345 ])
@@ -1372,8 +1349,8 @@
1372 mock_print.assert_has_calls([1349 mock_print.assert_has_calls([
1373 self.unit_pending_call,1350 self.unit_pending_call,
1374 self.machine_pending_call,1351 self.machine_pending_call,
1375 self.unit_placed_machine_call,
1376 self.machine_started_call,1352 self.machine_started_call,
1353 self.unit_placed_call,
1377 self.unit_installed_call,1354 self.unit_installed_call,
1378 self.unit_started_call,1355 self.unit_started_call,
1379 ])1356 ])
@@ -1382,21 +1359,23 @@
1382 # Strange unit evolutions are handled.1359 # Strange unit evolutions are handled.
1383 env = self.make_env([1360 env = self.make_env([
1384 # The unit is first reachable and then pending. The machine starts1361 # The unit is first reachable and then pending. The machine starts
1385 # when the unit is already installed. All of this makes no sense1362 # when the unit is already installed. The machine has an address
1386 # and should never happen, but if it does, we deal with it.1363 # when still provisioning. All of this makes no sense and should
1387 ([self.make_unit_change('pending', address=self.address)], []),1364 # never happen, but if it does, we deal with it.
1388 ([self.make_unit_change('pending', address='')],1365 ([self.make_unit_change('pending')], []),
1389 [self.make_machine_change('pending')]),1366 ([], [self.make_machine_change('pending', address=self.address)]),
1390 ([self.make_unit_change('installed', address=self.address)], []),1367 ([self.make_unit_change('pending')],
1391 ([], [self.make_machine_change('started')]),1368 [self.make_machine_change('pending', address='')]),
1392 ([self.make_unit_change('started', address=self.address)], []),1369 ([self.make_unit_change('installed')], []),
1370 ([], [self.make_machine_change('started', address=self.address)]),
1371 ([self.make_unit_change('started')], []),
1393 ])1372 ])
1394 address = app.watch(env, 'django/42')1373 address = app.watch(env, 'django/42')
1395 self.assertEqual(self.address, address)1374 self.assertEqual(self.address, address)
1396 self.assertEqual(6, mock_print.call_count)1375 self.assertEqual(6, mock_print.call_count)
1397 mock_print.assert_has_calls([1376 mock_print.assert_has_calls([
1398 self.unit_placed_unit_call,
1399 self.unit_pending_call,1377 self.unit_pending_call,
1378 self.unit_placed_call,
1400 self.machine_pending_call,1379 self.machine_pending_call,
1401 self.unit_installed_call,1380 self.unit_installed_call,
1402 self.machine_started_call,1381 self.machine_started_call,
@@ -1404,22 +1383,8 @@
1404 ])1383 ])
14051384
1406 def test_missing_changes(self, mock_print):1385 def test_missing_changes(self, mock_print):
1407 # Only the unit started change is strictly required when the unit1386 # Only the unit started change is strictly required when the machine
1408 # change includes the public address.1387 # change includes the addresses.
1409 env = self.make_env([
1410 ([self.make_unit_change('started', address=self.address)], []),
1411 ])
1412 address = app.watch(env, 'django/42')
1413 self.assertEqual(self.address, address)
1414 self.assertEqual(2, mock_print.call_count)
1415 mock_print.assert_has_calls([
1416 self.unit_placed_unit_call,
1417 self.unit_started_call,
1418 ])
1419
1420 def test_missing_changes_with_machine_address(self, mock_print):
1421 # When using the new mega-watcher, a machine change including its
1422 # public address is also required.
1423 env = self.make_env([1388 env = self.make_env([
1424 ([self.make_unit_change('started')], []),1389 ([self.make_unit_change('started')], []),
1425 ([], [self.make_machine_change('started', address=self.address)]),1390 ([], [self.make_machine_change('started', address=self.address)]),
@@ -1429,29 +1394,12 @@
1429 self.assertEqual(3, mock_print.call_count)1394 self.assertEqual(3, mock_print.call_count)
1430 mock_print.assert_has_calls([1395 mock_print.assert_has_calls([
1431 self.unit_started_call,1396 self.unit_started_call,
1432 self.unit_placed_machine_call,1397 self.unit_placed_call,
1433 self.machine_started_call,1398 self.machine_started_call,
1434 ])1399 ])
14351400
1436 def test_ignored_machine_changes(self, mock_print):1401 def test_ignored_machine_changes(self, mock_print):
1437 # All machine changes are ignored until the application knows what1402 # All machine changes are ignored until the application knows what
1438 # machine the unit belongs to.
1439 env = self.make_env([
1440 ([], [self.make_machine_change('pending')]),
1441 ([], [self.make_machine_change('started')]),
1442 ([self.make_unit_change('started', address=self.address)], []),
1443 ])
1444 address = app.watch(env, 'django/42')
1445 self.assertEqual(self.address, address)
1446 # No machine related messages have been printed.
1447 self.assertEqual(2, mock_print.call_count)
1448 mock_print.assert_has_calls([
1449 self.unit_placed_unit_call,
1450 self.unit_started_call,
1451 ])
1452
1453 def test_ignored_machine_changes_with_machine_address(self, mock_print):
1454 # All machine changes are ignored until the application knows what
1455 # machine the unit belongs to. When the above happens, previously1403 # machine the unit belongs to. When the above happens, previously
1456 # collected machine changes are still parsed in the case the address1404 # collected machine changes are still parsed in the case the address
1457 # is not yet known.1405 # is not yet known.
@@ -1468,7 +1416,7 @@
1468 self.assertEqual(3, mock_print.call_count)1416 self.assertEqual(3, mock_print.call_count)
1469 mock_print.assert_has_calls([1417 mock_print.assert_has_calls([
1470 self.unit_started_call,1418 self.unit_started_call,
1471 self.unit_placed_machine_call,1419 self.unit_placed_call,
1472 self.machine_started_call,1420 self.machine_started_call,
1473 ])1421 ])
14741422
@@ -1477,20 +1425,6 @@
1477 # This happens, e.g., when executing Quickstart a second time, and both1425 # This happens, e.g., when executing Quickstart a second time, and both
1478 # the unit and the machine are already started.1426 # the unit and the machine are already started.
1479 env = self.make_env([1427 env = self.make_env([
1480 ([self.make_unit_change('started', address=self.address)],
1481 [self.make_machine_change('started')]),
1482 ])
1483 address = app.watch(env, 'django/42')
1484 self.assertEqual(self.address, address)
1485 self.assertEqual(2, mock_print.call_count)
1486
1487 def test_unit_already_deployed_with_machine_address(self, mock_print):
1488 # Simulate the unit we are observing has been already deployed.
1489 # This happens, e.g., when executing Quickstart a second time, and both
1490 # the unit and the machine are already started.
1491 # This time the new mega-watcher behavior is simulated, in which
1492 # addresses are included in the machine change.
1493 env = self.make_env([
1494 ([self.make_unit_change('started')],1428 ([self.make_unit_change('started')],
1495 [self.make_machine_change('started', address=self.address)]),1429 [self.make_machine_change('started', address=self.address)]),
1496 ])1430 ])
@@ -1499,7 +1433,7 @@
1499 self.assertEqual(3, mock_print.call_count)1433 self.assertEqual(3, mock_print.call_count)
1500 mock_print.assert_has_calls([1434 mock_print.assert_has_calls([
1501 self.unit_started_call,1435 self.unit_started_call,
1502 self.unit_placed_machine_call,1436 self.unit_placed_call,
1503 self.machine_started_call,1437 self.machine_started_call,
1504 ])1438 ])
15051439
@@ -1509,31 +1443,6 @@
1509 # environment type: the unit is deployed on the bootstrap node, which1443 # environment type: the unit is deployed on the bootstrap node, which
1510 # is assumed to be started.1444 # is assumed to be started.
1511 env = self.make_env([1445 env = self.make_env([
1512 ([self.make_unit_change('pending', address='')],
1513 [self.make_machine_change('started')]),
1514 ([self.make_unit_change('pending', address=self.address)], []),
1515 ([self.make_unit_change('installed', address=self.address)], []),
1516 ([self.make_unit_change('started', address=self.address)], []),
1517 ])
1518 address = app.watch(env, 'django/42')
1519 self.assertEqual(self.address, address)
1520 self.assertEqual(5, mock_print.call_count)
1521 mock_print.assert_has_calls([
1522 self.unit_pending_call,
1523 self.machine_started_call,
1524 self.unit_placed_unit_call,
1525 self.unit_installed_call,
1526 self.unit_started_call,
1527 ])
1528
1529 def test_machine_already_started_with_machine_address(self, mock_print):
1530 # Simulate the unit is being deployed on an already started machine.
1531 # This happens, e.g., when running Quickstart on a non-local
1532 # environment type: the unit is deployed on the bootstrap node, which
1533 # is assumed to be started.
1534 # This time the new mega-watcher behavior is simulated, in which
1535 # addresses are included in the machine change.
1536 env = self.make_env([
1537 ([self.make_unit_change('pending')],1446 ([self.make_unit_change('pending')],
1538 [self.make_machine_change('started', address=self.address)]),1447 [self.make_machine_change('started', address=self.address)]),
1539 ([self.make_unit_change('pending')], []),1448 ([self.make_unit_change('pending')], []),
@@ -1545,7 +1454,7 @@
1545 self.assertEqual(5, mock_print.call_count)1454 self.assertEqual(5, mock_print.call_count)
1546 mock_print.assert_has_calls([1455 mock_print.assert_has_calls([
1547 self.unit_pending_call,1456 self.unit_pending_call,
1548 self.unit_placed_machine_call,1457 self.unit_placed_call,
1549 self.machine_started_call,1458 self.machine_started_call,
1550 self.unit_installed_call,1459 self.unit_installed_call,
1551 self.unit_started_call,1460 self.unit_started_call,
@@ -1555,24 +1464,26 @@
1555 # Changes to units or machines we are not observing are ignored. Also1464 # Changes to units or machines we are not observing are ignored. Also
1556 # ensure that repeated changes to a single entity are ignored, even if1465 # ensure that repeated changes to a single entity are ignored, even if
1557 # they are unlikely to happen.1466 # they are unlikely to happen.
1558 pending_unit_change = self.make_unit_change('pending', address='')1467 pending_unit_change = self.make_unit_change('pending')
1559 started_unit_change = self.make_unit_change(1468 started_unit_change = self.make_unit_change('started')
1560 'started', address=self.address)1469 pending_machine_change = self.make_machine_change('pending')
1561 env = self.make_env([1470 env = self.make_env([
1562 # Add a repeated change.1471 # Add a repeated unit change.
1563 ([pending_unit_change, pending_unit_change],1472 ([pending_unit_change, pending_unit_change],
1564 [self.make_machine_change('pending')]),1473 [pending_machine_change]),
1565 # Add extraneous unit and machine changes.1474 # Add extraneous unit and machine changes.
1566 ([self.make_unit_change('pending', name='haproxy/0')],1475 ([self.make_unit_change('pending', name='haproxy/0')],
1567 [self.make_machine_change('pending', name='42')]),1476 [self.make_machine_change('pending', name='42')]),
1477 # Add a repeated machine change.
1478 ([], [pending_machine_change, pending_machine_change]),
1568 # Add a change to an extraneous machine.1479 # Add a change to an extraneous machine.
1569 ([], [self.make_machine_change('started', name='42'),1480 ([], [self.make_machine_change('started', name='42'),
1570 self.make_machine_change('started')]),1481 self.make_machine_change('started', address=self.address)]),
1571 # Add a change to an extraneous unit.1482 # Add a change to an extraneous unit.
1572 ([self.make_unit_change('started', name='haproxy/0'),1483 ([self.make_unit_change('started', name='haproxy/0'),
1573 self.make_unit_change('pending', address=self.address)], []),1484 self.make_unit_change('pending')], []),
1574 ([self.make_unit_change('installed', address=self.address)], []),1485 ([self.make_unit_change('installed')], []),
1575 # Add another repeated change.1486 # Add another repeated unit change.
1576 ([started_unit_change, started_unit_change], []),1487 ([started_unit_change, started_unit_change], []),
1577 ])1488 ])
1578 address = app.watch(env, 'django/42')1489 address = app.watch(env, 'django/42')
@@ -1581,8 +1492,8 @@
1581 mock_print.assert_has_calls([1492 mock_print.assert_has_calls([
1582 self.unit_pending_call,1493 self.unit_pending_call,
1583 self.machine_pending_call,1494 self.machine_pending_call,
1495 self.unit_placed_call,
1584 self.machine_started_call,1496 self.machine_started_call,
1585 self.unit_placed_unit_call,
1586 self.unit_installed_call,1497 self.unit_installed_call,
1587 self.unit_started_call,1498 self.unit_started_call,
1588 ])1499 ])
@@ -1590,7 +1501,7 @@
1590 def test_api_error(self, mock_print):1501 def test_api_error(self, mock_print):
1591 # A ProgramExit is raised if an error occurs in one of the API calls.1502 # A ProgramExit is raised if an error occurs in one of the API calls.
1592 env = self.make_env([1503 env = self.make_env([
1593 ([self.make_unit_change('pending', address='')], []),1504 ([self.make_unit_change('pending')], []),
1594 self.make_env_error('next returned an error'),1505 self.make_env_error('next returned an error'),
1595 ])1506 ])
1596 expected = 'bad API server response: next returned an error'1507 expected = 'bad API server response: next returned an error'
@@ -1602,14 +1513,13 @@
1602 def test_other_errors(self, mock_print):1513 def test_other_errors(self, mock_print):
1603 # Any other errors occurred during the process are not trapped.1514 # Any other errors occurred during the process are not trapped.
1604 env = self.make_env([1515 env = self.make_env([
1605 ([self.make_unit_change('installed', address=self.address)], []),1516 ([self.make_unit_change('installed')], []),
1606 ValueError('explode!'),1517 ValueError('explode!'),
1607 ])1518 ])
1608 with self.assert_value_error('explode!'):1519 with self.assert_value_error('explode!'):
1609 app.watch(env, 'django/42')1520 app.watch(env, 'django/42')
1610 self.assertEqual(2, mock_print.call_count)1521 self.assertEqual(1, mock_print.call_count)
1611 mock_print.assert_has_calls([1522 mock_print.assert_has_calls([self.unit_installed_call])
1612 self.unit_placed_unit_call, self.unit_installed_call])
16131523
1614 def test_machine_status_error(self, mock_print):1524 def test_machine_status_error(self, mock_print):
1615 # A ProgramExit is raised if an the machine is found in an error state.1525 # A ProgramExit is raised if an the machine is found in an error state.
@@ -1622,8 +1532,7 @@
1622 # The unit pending change is required to make the function know which1532 # The unit pending change is required to make the function know which
1623 # machine to observe.1533 # machine to observe.
1624 env = self.make_env([1534 env = self.make_env([
1625 ([self.make_unit_change('pending', address='')],1535 ([self.make_unit_change('pending')], [change_machine_error]),
1626 [change_machine_error]),
1627 ])1536 ])
1628 expected = 'machine 0 is in an error state: error: oddities'1537 expected = 'machine 0 is in an error state: error: oddities'
1629 with self.assert_program_exit(expected):1538 with self.assert_program_exit(expected):
@@ -1677,32 +1586,3 @@
1677 with self.assertRaises(ValueError) as context_manager:1586 with self.assertRaises(ValueError) as context_manager:
1678 app.deploy_bundle(env, self.yaml, self.name, None)1587 app.deploy_bundle(env, self.yaml, self.name, None)
1679 self.assertIs(error, context_manager.exception)1588 self.assertIs(error, context_manager.exception)
1680
1681
1682class TestJujuRequiresSudo(ProgramExitTestsMixin, unittest.TestCase):
1683 no_sudo_versions = [
1684 (1, 17, 2), (1, 17, 10), (1, 18, 0), (1, 18, 2), (2, 16, 1)]
1685 sudo_versions = [(0, 7, 9), (1, 0, 0), (1, 16, 42), (1, 17, 0), (1, 17, 1)]
1686
1687 def test_non_local_returns_false(self):
1688 # A non-local provider does not require sudo.
1689 actual = app.juju_requires_sudo('aws', None, None)
1690 self.assertFalse(actual)
1691
1692 def test_local_old_juju_returns_true(self):
1693 # A juju previous to 1.17.2 requires sudo.
1694 for version in self.sudo_versions:
1695 self.assertTrue(app.juju_requires_sudo('local', version, False),
1696 version)
1697
1698 def test_local_newer_juju_returns_false(self):
1699 # A juju 1.17.2 and newer does not require sudo.
1700 for version in self.no_sudo_versions:
1701 self.assertFalse(app.juju_requires_sudo('local', version, False),
1702 version)
1703
1704 def test_raises_programexit(self):
1705 for version in self.sudo_versions:
1706 with self.assert_program_exit('cannot use a customized Juju when '
1707 'it requires sudo'):
1708 app.juju_requires_sudo('local', version, True)
17091589
=== modified file 'quickstart/tests/test_manage.py'
--- quickstart/tests/test_manage.py 2014-08-25 16:57:41 +0000
+++ quickstart/tests/test_manage.py 2014-11-10 12:30:00 +0000
@@ -795,7 +795,6 @@
795 mock_app.watch.return_value = '1.2.3.4'795 mock_app.watch.return_value = '1.2.3.4'
796 # Make mock_app.create_auth_token return a fake authentication token.796 # Make mock_app.create_auth_token return a fake authentication token.
797 mock_app.create_auth_token.return_value = 'AUTHTOKEN'797 mock_app.create_auth_token.return_value = 'AUTHTOKEN'
798 mock_app.juju_requires_sudo.return_value = False
799 options = self.make_options()798 options = self.make_options()
800 with mock.patch('quickstart.manage.platform_support.get_juju_command',799 with mock.patch('quickstart.manage.platform_support.get_juju_command',
801 side_effect=[(self.juju_command, False)]):800 side_effect=[(self.juju_command, False)]):
@@ -803,8 +802,9 @@
803 mock_app.ensure_dependencies.assert_called()802 mock_app.ensure_dependencies.assert_called()
804 mock_app.ensure_ssh_keys.assert_called()803 mock_app.ensure_ssh_keys.assert_called()
805 mock_app.bootstrap.assert_called_once_with(804 mock_app.bootstrap.assert_called_once_with(
806 options.env_name, self.juju_command, requires_sudo=False,805 options.env_name, self.juju_command,
807 debug=options.debug, upload_tools=options.upload_tools,806 debug=options.debug,
807 upload_tools=options.upload_tools,
808 upload_series=options.upload_series,808 upload_series=options.upload_series,
809 constraints=options.constraints)809 constraints=options.constraints)
810 mock_app.get_api_url.assert_called_once_with(810 mock_app.get_api_url.assert_called_once_with(
@@ -879,68 +879,19 @@
879 # where to deploy the charm, the service and unit data.879 # where to deploy the charm, the service and unit data.
880 mock_app.check_environment.return_value = (880 mock_app.check_environment.return_value = (
881 'cs:precise/juju-gui-42', '0', None, None)881 'cs:precise/juju-gui-42', '0', None, None)
882 mock_app.juju_requires_sudo.return_value = False882 for version in versions:
883 for version in versions:883 mock_app.ensure_dependencies.return_value = version
884 mock_app.ensure_dependencies.return_value = version884 with mock.patch(
885 with mock.patch(885 'quickstart.manage.platform_support.get_juju_command',
886 'quickstart.manage.platform_support.get_juju_command',886 side_effect=[(self.juju_command, False)]):
887 side_effect=[(self.juju_command, False)]):887 manage.run(options)
888 manage.run(options)888 mock_app.bootstrap.assert_called_once_with(
889 mock_app.bootstrap.assert_called_once_with(889 options.env_name, self.juju_command,
890 options.env_name, self.juju_command, requires_sudo=False,890 debug=options.debug,
891 debug=options.debug, upload_tools=options.upload_tools,891 upload_tools=options.upload_tools,
892 upload_series=options.upload_series,892 upload_series=options.upload_series,
893 constraints=options.constraints)893 constraints=options.constraints)
894 mock_app.bootstrap.reset_mock()894 mock_app.bootstrap.reset_mock()
895
896 def test_local_provider_requiring_sudo(self, mock_app, mock_open):
897 # The application correctly handles working with local providers when
898 # Juju requires an external "sudo" call to bootstrap the environment.
899 # Sudo privileges are required if the Juju version is < 1.17.2.
900 options = self.make_options(env_type='local')
901 versions = [(0, 7, 9), (1, 0, 0), (1, 16, 42), (1, 17, 0), (1, 17, 1)]
902 # Make mock_app.bootstrap return the already_bootstrapped flag and the
903 # bootstrap node series.
904 mock_app.bootstrap.return_value = (True, 'trusty')
905 # Make mock_app.check_environment return the charm URL, the machine
906 # where to deploy the charm, the service and unit data.
907 mock_app.check_environment.return_value = (
908 'cs:trusty/juju-gui-42', '0', None, None)
909 mock_app.juju_requires_sudo.return_value = True
910 for version in versions:
911 mock_app.ensure_dependencies.return_value = version
912 with mock.patch(
913 'quickstart.manage.platform_support.get_juju_command',
914 side_effect=[(self.juju_command, False)]):
915 manage.run(options)
916 mock_app.bootstrap.assert_called_once_with(
917 options.env_name, self.juju_command, requires_sudo=True,
918 debug=options.debug, upload_tools=options.upload_tools,
919 upload_series=options.upload_series,
920 constraints=options.constraints)
921 mock_app.bootstrap.reset_mock()
922
923 def test_no_local_no_sudo(self, mock_app, mock_open):
924 # Sudo privileges are never required for non-local environments.
925 options = self.make_options(env_type='ec2')
926 mock_app.ensure_dependencies.return_value = (1, 14, 0)
927 # Make mock_app.bootstrap return the already_bootstrapped flag and the
928 # bootstrap node series.
929 mock_app.bootstrap.return_value = (True, 'precise')
930 # Make mock_app.check_environment return the charm URL, the machine
931 # where to deploy the charm, the service and unit data.
932 mock_app.check_environment.return_value = (
933 'cs:precise/juju-gui-42', '0', None, None)
934 mock_app.juju_requires_sudo.return_value = False
935 with mock.patch('quickstart.manage.platform_support.get_juju_command',
936 side_effect=[(self.juju_command, False)]):
937 manage.run(options)
938 mock_app.bootstrap.assert_called_once_with(
939 options.env_name, self.juju_command,
940 requires_sudo=False,
941 debug=options.debug, upload_tools=options.upload_tools,
942 upload_series=options.upload_series,
943 constraints=options.constraints)
944895
945 def test_no_browser(self, mock_app, mock_open):896 def test_no_browser(self, mock_app, mock_open):
946 # It is possible to avoid opening the GUI in the browser.897 # It is possible to avoid opening the GUI in the browser.
@@ -1015,14 +966,14 @@
1015 mock_app.bootstrap.return_value = (True, 'precise')966 mock_app.bootstrap.return_value = (True, 'precise')
1016 mock_app.check_environment.return_value = (967 mock_app.check_environment.return_value = (
1017 'cs:precise/juju-gui-42', '0', None, None)968 'cs:precise/juju-gui-42', '0', None, None)
1018 mock_app.juju_requires_sudo.return_value = False
1019 options = self.make_options(env_type='aws')969 options = self.make_options(env_type='aws')
1020 juju_command = 'some/devel/path/juju'970 juju_command = 'some/devel/path/juju'
1021 with mock.patch('os.environ', {'JUJU': juju_command}):971 with mock.patch('os.environ', {'JUJU': juju_command}):
1022 manage.run(options)972 manage.run(options)
1023 mock_app.bootstrap.assert_called_once_with(973 mock_app.bootstrap.assert_called_once_with(
1024 options.env_name, juju_command, requires_sudo=False,974 options.env_name, juju_command,
1025 debug=options.debug, upload_tools=options.upload_tools,975 debug=options.debug,
976 upload_tools=options.upload_tools,
1026 upload_series=options.upload_series,977 upload_series=options.upload_series,
1027 constraints=options.constraints)978 constraints=options.constraints)
1028 mock_app.get_api_url.assert_called_once_with(979 mock_app.get_api_url.assert_called_once_with(
1029980
=== modified file 'quickstart/tests/test_utils.py'
--- quickstart/tests/test_utils.py 2014-08-22 16:38:23 +0000
+++ quickstart/tests/test_utils.py 2014-11-10 12:30:00 +0000
@@ -156,6 +156,24 @@
156 utils.call('echo', 'we are the borg!')156 utils.call('echo', 'we are the borg!')
157157
158158
159class TestCheckResolvable(unittest.TestCase):
160
161 def test_resolvable(self):
162 # None is returned if the hostname can be resolved.
163 expected_log = 'example.com resolved to 1.2.3.4'
164 with helpers.assert_logs([expected_log], level='debug'):
165 with mock.patch('socket.gethostbyname', return_value='1.2.3.4'):
166 error = utils.check_resolvable('example.com')
167 self.assertIsNone(error)
168
169 def test_not_resolvable(self):
170 # An error message is returned if the hostname cannot be resolved.
171 exception = socket.gaierror('bad wolf')
172 with mock.patch('socket.gethostbyname', side_effect=exception):
173 error = utils.check_resolvable('example.com')
174 self.assertEqual('bad wolf', error)
175
176
159@mock.patch('__builtin__.print', mock.Mock())177@mock.patch('__builtin__.print', mock.Mock())
160class TestParseGuiCharmUrl(unittest.TestCase):178class TestParseGuiCharmUrl(unittest.TestCase):
161179
162180
=== modified file 'quickstart/tests/test_watchers.py'
--- quickstart/tests/test_watchers.py 2014-07-04 13:09:11 +0000
+++ quickstart/tests/test_watchers.py 2014-11-10 12:30:00 +0000
@@ -63,23 +63,29 @@
6363
64class TestRetrievePublicAddress(unittest.TestCase):64class TestRetrievePublicAddress(unittest.TestCase):
6565
66 def resolver(self, hostname):
67 """A fake resolver returning no errors."""
68 return None
69
66 def test_empty_addresses(self):70 def test_empty_addresses(self):
67 # None is returned if there are no available addresses.71 # None is returned if there are no available addresses.
68 self.assertIsNone(watchers.retrieve_public_adddress([]))72 address = watchers.retrieve_public_adddress([], self.resolver)
73 self.assertIsNone(address)
6974
70 def test_cloud_address_not_found(self):75 def test_cloud_address_not_found(self):
71 # None is returned if a cloud machine public address is not available.76 # None is returned if a cloud machine public address is not available.
72 addresses = [77 addresses = [
73 {'NetworkName': '',78 {'NetworkName': '',
74 'Scope': 'local-cloud',79 'Scope': 'public',
75 'Type': 'hostname',80 'Type': 'ipv6',
76 'Value': 'eu-west-1.example.internal'},81 'Value': 'fe80::92b8:d0ff:fe94:8f8c'},
77 {'NetworkName': '',82 {'NetworkName': '',
78 'Scope': 'local-cloud',83 'Scope': 'local-cloud',
79 'Type': 'ipv4',84 'Type': 'ipv6',
80 'Value': '10.42.47.10'},85 'Value': 'fe80::216:3eff:fefd:787e'},
81 ]86 ]
82 self.assertIsNone(watchers.retrieve_public_adddress(addresses))87 address = watchers.retrieve_public_adddress(addresses, self.resolver)
88 self.assertIsNone(address)
8389
84 def test_container_address_not_found(self):90 def test_container_address_not_found(self):
85 # None is returned if an LXC public address is not available.91 # None is returned if an LXC public address is not available.
@@ -89,7 +95,8 @@
89 'Type': 'ipv6',95 'Type': 'ipv6',
90 'Value': 'fe80::216:3eff:fefd:787e',96 'Value': 'fe80::216:3eff:fefd:787e',
91 }]97 }]
92 self.assertIsNone(watchers.retrieve_public_adddress(addresses))98 address = watchers.retrieve_public_adddress(addresses, self.resolver)
99 self.assertIsNone(address)
93100
94 def test_empty_public_address(self):101 def test_empty_public_address(self):
95 # None is returned if the public address has no value.102 # None is returned if the public address has no value.
@@ -103,17 +110,20 @@
103 'Type': 'ipv4',110 'Type': 'ipv4',
104 'Value': ''},111 'Value': ''},
105 ]112 ]
106 self.assertIsNone(watchers.retrieve_public_adddress(addresses))113 address = watchers.retrieve_public_adddress(addresses, self.resolver)
114 self.assertIsNone(address)
107115
108 def test_cloud_addresses(self):116 def test_cloud_addresses(self):
109 # The public address of a cloud machine is properly returned.117 # The public address of a cloud machine is properly returned.
110 public_address = watchers.retrieve_public_adddress(cloud_addresses)118 address = watchers.retrieve_public_adddress(
111 self.assertEqual('eu-west-1.compute.example.com', public_address)119 cloud_addresses, self.resolver)
120 self.assertEqual('eu-west-1.compute.example.com', address)
112121
113 def test_container_addresses(self):122 def test_container_addresses(self):
114 # The public address of an LXC instance is properly returned.123 # The public address of an LXC instance is properly returned.
115 public_address = watchers.retrieve_public_adddress(container_addresses)124 address = watchers.retrieve_public_adddress(
116 self.assertEqual('10.0.3.42', public_address)125 container_addresses, self.resolver)
126 self.assertEqual('10.0.3.42', address)
117127
118 def test_old_juju_version(self):128 def test_old_juju_version(self):
119 # The public address is properly returned when using Juju < 1.20.129 # The public address is properly returned when using Juju < 1.20.
@@ -129,11 +139,32 @@
129 'Type': 'hostname',139 'Type': 'hostname',
130 'Value': 'eu-west-1.example.internal'},140 'Value': 'eu-west-1.example.internal'},
131 ]141 ]
132 public_address = watchers.retrieve_public_adddress(addresses)142 address = watchers.retrieve_public_adddress(addresses, self.resolver)
133 self.assertEqual('eu-west-1.compute.example.com', public_address)143 self.assertEqual('eu-west-1.compute.example.com', address)
134144
135 def test_last_unknown_address(self):145 def test_local_cloud_address(self):
136 # If the scope of multiple addresses is unknown, the last one is taken.146 # If there are no available public addresses the first ipv4 address
147 # with cloud-local scope is returned.
148 addresses = [
149 {'NetworkName': '',
150 'Scope': 'local-cloud',
151 'Type': 'hostname',
152 'Value': 'eu-west-1.example.internal'},
153 {'NetworkName': '',
154 'Scope': 'local-cloud',
155 'Type': 'ipv4',
156 'Value': '10.42.47.10'},
157 {'NetworkName': '',
158 'Scope': 'local-cloud',
159 'Type': 'ipv4',
160 'Value': '1.2.3.4'},
161 ]
162 address = watchers.retrieve_public_adddress(addresses, self.resolver)
163 self.assertEqual('10.42.47.10', address)
164
165 def test_unknown_address(self):
166 # If there are no available public or local-cloud addresses, the first
167 # address with unknown scope is returned.
137 addresses = [168 addresses = [
138 {'NetworkName': '',169 {'NetworkName': '',
139 'Scope': '',170 'Scope': '',
@@ -144,8 +175,52 @@
144 'Type': 'ipv4',175 'Type': 'ipv4',
145 'Value': '10.0.3.47'},176 'Value': '10.0.3.47'},
146 ]177 ]
147 public_address = watchers.retrieve_public_adddress(addresses)178 address = watchers.retrieve_public_adddress(addresses, self.resolver)
148 self.assertEqual('10.0.3.47', public_address)179 self.assertEqual('10.0.3.42', address)
180
181 def test_preferred_fallback_address(self):
182 # If there are no available public addresses the first fallback address
183 # in the list is returned.
184 addresses = [
185 {'NetworkName': '',
186 'Scope': '',
187 'Type': 'ipv4',
188 'Value': '10.0.3.47'},
189 {'NetworkName': '',
190 'Scope': 'local-cloud',
191 'Type': 'ipv4',
192 'Value': '10.42.47.10'},
193
194 ]
195 address = watchers.retrieve_public_adddress(addresses, self.resolver)
196 self.assertEqual('10.0.3.47', address)
197 # Now test with a reversed order.
198 address = watchers.retrieve_public_adddress(
199 reversed(addresses), self.resolver)
200 self.assertEqual('10.42.47.10', address)
201
202 def test_unresolvable_public_address(self):
203 # None is returned if a public cloud is found but it is not resolvable.
204 addresses = [
205 {'NetworkName': '',
206 'Scope': 'public',
207 'Type': 'hostname',
208 'Value': 'eu-west-1.example.internal'},
209 ]
210 expected_warning = (
211 'cannot resolve public eu-west-1.example.internal address, '
212 'looking for another candidate: bad wolf')
213 with helpers.assert_logs([expected_warning], level='warn'):
214 address = watchers.retrieve_public_adddress(
215 addresses, lambda hostname: 'bad wolf')
216 self.assertIsNone(address)
217
218 def test_unresolvable_public_address_fallback(self):
219 # If there are no resolvable public addresses the first ipv4 address
220 # with cloud-local scope is returned.
221 address = watchers.retrieve_public_adddress(
222 cloud_addresses, lambda hostname: 'bad wolf')
223 self.assertEqual('10.42.47.10', address)
149224
150225
151class TestParseMachineChange(helpers.ValueErrorTestsMixin, unittest.TestCase):226class TestParseMachineChange(helpers.ValueErrorTestsMixin, unittest.TestCase):
@@ -205,8 +280,12 @@
205 # A message is printed to stdout when the machine obtains a public280 # A message is printed to stdout when the machine obtains a public
206 # address.281 # address.
207 data = {'Addresses': cloud_addresses, 'Id': '1', 'Status': 'pending'}282 data = {'Addresses': cloud_addresses, 'Id': '1', 'Status': 'pending'}
208 status, address = watchers.parse_machine_change(283 # Patch the hostname resolver used to check hostname addresses.
209 'change', data, 'pending', '')284 # Note that resolve error cases are properly exercised by the
285 # watchers.retrieve_public_adddress tests.
286 with helpers.patch_check_resolvable():
287 status, address = watchers.parse_machine_change(
288 'change', data, 'pending', '')
210 self.assertEqual('pending', status)289 self.assertEqual('pending', status)
211 self.assertEqual('eu-west-1.compute.example.com', address)290 self.assertEqual('eu-west-1.compute.example.com', address)
212 mock_print.assert_called_once_with(291 mock_print.assert_called_once_with(
@@ -248,8 +327,8 @@
248 # A ValueError is raised if the change represents a unit removal.327 # A ValueError is raised if the change represents a unit removal.
249 data = {'Name': 'django/42', 'Status': 'started'}328 data = {'Name': 'django/42', 'Status': 'started'}
250 with self.assert_value_error('django/42 unexpectedly removed'):329 with self.assert_value_error('django/42 unexpectedly removed'):
251 # The last two arguments are the current status and address.330 # The last argument is the current status.
252 watchers.parse_unit_change('remove', data, '', '')331 watchers.parse_unit_change('remove', data, '')
253332
254 def test_unit_error(self):333 def test_unit_error(self):
255 # A ValueError is raised if the unit is in an error state.334 # A ValueError is raised if the unit is in an error state.
@@ -260,120 +339,55 @@
260 }339 }
261 expected_error = 'django/0 is in an error state: start error: bad wolf'340 expected_error = 'django/0 is in an error state: start error: bad wolf'
262 with self.assert_value_error(expected_error):341 with self.assert_value_error(expected_error):
263 # The last two arguments are the current status and address.342 # The last argument is the current status.
264 watchers.parse_unit_change('change', data, '', '')343 watchers.parse_unit_change('change', data, '')
265
266 @helpers.mock_print
267 def test_address_notified(self, mock_print):
268 # A message is printed to stdout when the unit obtains a public
269 # address. The function returns the status, the new address and the
270 # machine identifier.
271 data = {
272 'Name': 'haproxy/2',
273 'Status': 'pending',
274 'PublicAddress': 'haproxy2.example.com',
275 'MachineId': '42',
276 }
277 status, address, machine_id = watchers.parse_unit_change(
278 'change', data, 'pending', '')
279 self.assertEqual('pending', status)
280 self.assertEqual('haproxy2.example.com', address)
281 self.assertEqual('42', machine_id)
282 mock_print.assert_called_once_with(
283 'haproxy/2 placed on haproxy2.example.com')
284344
285 @helpers.mock_print345 @helpers.mock_print
286 def test_pending_status_notified(self, mock_print):346 def test_pending_status_notified(self, mock_print):
287 # A message is printed to stdout when the unit changes its status to347 # A message is printed to stdout when the unit changes its status to
288 # "pending". The function returns the new status, the address and the348 # "pending". The function returns the new status and the machine
289 # machine identifier. The last two values are empty strings if the unit349 # identifier, which is empty if the unit has not yet been assigned to a
290 # has not yet been assigned to a machine.350 # machine.
291 data = {'Name': 'django/1', 'Status': 'pending', 'PublicAddress': ''}351 data = {'Name': 'django/1', 'Status': 'pending'}
292 # The last two arguments are the current status and address.352 # The last argument is the current status.
293 status, address, machine_id = watchers.parse_unit_change(353 status, machine_id = watchers.parse_unit_change('change', data, '')
294 'change', data, '', '')
295 self.assertEqual('pending', status)354 self.assertEqual('pending', status)
296 self.assertEqual('', address)
297 self.assertEqual('', machine_id)355 self.assertEqual('', machine_id)
298 mock_print.assert_called_once_with('django/1 deployment is pending')356 mock_print.assert_called_once_with('django/1 deployment is pending')
299357
300 @helpers.mock_print358 @helpers.mock_print
301 def test_installed_status_notified(self, mock_print):359 def test_installed_status_notified(self, mock_print):
302 # A message is printed to stdout when the unit changes its status to360 # A message is printed to stdout when the unit changes its status to
303 # "installed". The function returns the new status, the address and the361 # "installed".
304 # machine identifier.362 data = {'Name': 'django/42', 'Status': 'installed', 'MachineId': '1'}
305 data = {363 status, machine_id = watchers.parse_unit_change(
306 'Name': 'django/42',364 'change', data, 'pending')
307 'Status': 'installed',
308 'PublicAddress': 'django42.example.com',
309 'MachineId': '1',
310 }
311 status, address, machine_id = watchers.parse_unit_change(
312 'change', data, 'pending', 'django42.example.com')
313 self.assertEqual('installed', status)365 self.assertEqual('installed', status)
314 self.assertEqual('django42.example.com', address)
315 self.assertEqual('1', machine_id)366 self.assertEqual('1', machine_id)
316 mock_print.assert_called_once_with('django/42 is installed')367 mock_print.assert_called_once_with('django/42 is installed')
317368
318 @helpers.mock_print369 @helpers.mock_print
319 def test_started_status_notified(self, mock_print):370 def test_started_status_notified(self, mock_print):
320 # A message is printed to stdout when the unit changes its status to371 # A message is printed to stdout when the unit changes its status to
321 # "started". The function returns the new status, the address and the372 # "started".
322 # machine identifier.373 data = {'Name': 'wordpress/0', 'Status': 'started', 'MachineId': '0'}
323 data = {374 # The last argument is the current status.
324 'Name': 'wordpress/0',375 status, machine_id = watchers.parse_unit_change('change', data, '')
325 'Status': 'started',
326 'PublicAddress': 'wordpress0.example.com',
327 'MachineId': '0',
328 }
329 status, address, machine_id = watchers.parse_unit_change(
330 'change', data, '', 'wordpress0.example.com')
331 self.assertEqual('started', status)376 self.assertEqual('started', status)
332 self.assertEqual('wordpress0.example.com', address)
333 self.assertEqual('0', machine_id)377 self.assertEqual('0', machine_id)
334 mock_print.assert_called_once_with('wordpress/0 is ready on machine 0')378 mock_print.assert_called_once_with('wordpress/0 is ready on machine 0')
335379
336 @helpers.mock_print380 @helpers.mock_print
337 def test_both_status_and_address_notified(self, mock_print):
338 # Both status and public address changes are notified if required.
339 data = {
340 'Name': 'django/0',
341 'Status': 'started',
342 'PublicAddress': 'django42.example.com',
343 'MachineId': '0',
344 }
345 # The last two arguments are the current status and address.
346 watchers.parse_unit_change('change', data, '', '')
347 self.assertEqual(2, mock_print.call_count)
348 mock_print.assert_has_calls([
349 mock.call('django/0 placed on django42.example.com'),
350 mock.call('django/0 is ready on machine 0'),
351 ])
352
353 @helpers.mock_print
354 def test_status_not_changed(self, mock_print):381 def test_status_not_changed(self, mock_print):
355 # If the status in the unit change and the given current status are the382 # If the status in the unit change and the given current status are the
356 # same value, nothing is printed and the current values are returned.383 # same value, nothing is printed and the current values are returned.
357 data = {'Name': 'django/1', 'Status': 'pending', 'PublicAddress': ''}384 data = {'Name': 'django/1', 'Status': 'pending'}
358 status, address, machine_id = watchers.parse_unit_change(385 status, machine_id = watchers.parse_unit_change(
359 'change', data, 'pending', '')386 'change', data, 'pending')
360 self.assertEqual('pending', status)387 self.assertEqual('pending', status)
361 self.assertEqual('', address)
362 self.assertEqual('', machine_id)388 self.assertEqual('', machine_id)
363 self.assertFalse(mock_print.called)389 self.assertFalse(mock_print.called)
364390
365 @helpers.mock_print
366 def test_address_not_available(self, mock_print):
367 # An empty address is returned when the public address field is not
368 # included in the change data.
369 data = {'Name': 'haproxy/2', 'Status': 'pending', 'MachineId': '42'}
370 status, address, machine_id = watchers.parse_unit_change(
371 'change', data, 'pending', '')
372 self.assertEqual('pending', status)
373 self.assertEqual('', address)
374 self.assertEqual('42', machine_id)
375 self.assertFalse(mock_print.called)
376
377391
378class TestUnitMachineChanges(unittest.TestCase):392class TestUnitMachineChanges(unittest.TestCase):
379393
380394
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2014-08-22 18:32:15 +0000
+++ quickstart/utils.py 2014-11-10 12:30:00 +0000
@@ -105,6 +105,20 @@
105 return retcode, output.decode('utf-8'), error.decode('utf-8')105 return retcode, output.decode('utf-8'), error.decode('utf-8')
106106
107107
108def check_resolvable(hostname):
109 """Check that the hostname can be resolved to a numeric IP address.
110
111 Return an error message if the address cannot be resolved.
112 """
113 try:
114 address = socket.gethostbyname(hostname)
115 except socket.error as err:
116 return bytes(err).decode('utf-8')
117 logging.debug('{} resolved to {}'.format(
118 hostname, address.decode('utf-8')))
119 return None
120
121
108def parse_gui_charm_url(charm_url):122def parse_gui_charm_url(charm_url):
109 """Parse the given charm URL.123 """Parse the given charm URL.
110124
111125
=== modified file 'quickstart/watchers.py'
--- quickstart/watchers.py 2014-07-04 13:09:11 +0000
+++ quickstart/watchers.py 2014-11-10 12:30:00 +0000
@@ -21,15 +21,25 @@
21 unicode_literals,21 unicode_literals,
22)22)
2323
2424import logging
25
26from quickstart import utils
27
28
29IPV4_ADDRESS = 'ipv4'
25IPV6_ADDRESS = 'ipv6'30IPV6_ADDRESS = 'ipv6'
26NETWORK_PUBLIC = 'public'31SCOPE_CLOUD_LOCAL = 'local-cloud'
27NETWORK_UNKNOWN = ''32SCOPE_PUBLIC = 'public'
2833SCOPE_UNKNOWN = ''
2934
30def retrieve_public_adddress(addresses):35
36def retrieve_public_adddress(addresses, hostname_resolver):
31 """Parse the given addresses and return a public address if available.37 """Parse the given addresses and return a public address if available.
3238
39 Use the given hostname_resolver callable to ensure a candidate address can
40 be resolved. The given function must accept an hostname/ip address and
41 return None if the address is resolvable, or an error string otherwise.
42
33 The addresses argument is a list of address dictionaries.43 The addresses argument is a list of address dictionaries.
34 Cloud addresses look like the following:44 Cloud addresses look like the following:
3545
@@ -73,8 +83,8 @@
73 found, this function returns None.83 found, this function returns None.
74 """84 """
75 # This implementation reflects how the public address is retrieved in Juju:85 # This implementation reflects how the public address is retrieved in Juju:
76 # see juju-core/instance/address.go:SelectPublicAddress.86 # see juju-core/network/address.go:SelectPublicAddress.
77 public_address = None87 fallback_address = None
78 for address in addresses:88 for address in addresses:
79 value = address['Value']89 value = address['Value']
80 # Exclude empty values and ipv6 addresses.90 # Exclude empty values and ipv6 addresses.
@@ -82,14 +92,33 @@
82 # The address scope was called "NetworkScope" prior to juju 1.20.0.92 # The address scope was called "NetworkScope" prior to juju 1.20.0.
83 scope = address.get('Scope', address.get('NetworkScope'))93 scope = address.get('Scope', address.get('NetworkScope'))
84 # If the scope is public then we have found the address.94 # If the scope is public then we have found the address.
85 if scope == NETWORK_PUBLIC:95 if scope == SCOPE_PUBLIC:
86 return value96 error = hostname_resolver(value)
87 # If the scope is unknown then store the value. This way the last97 if error is None:
88 # address with unknown scope will be returned, and we are able to98 return value
89 # return the right LXC address.99 # If the address is not resolvable, fall back to the next
90 if scope == NETWORK_UNKNOWN:100 # candidate, whether it is another public address, a
91 public_address = value101 # local-cloud one or an unknown one. The local-cloud case, for
92 return public_address102 # instance, would happen when using the maas provider and the
103 # maas DNS server is not configured locally.
104 logging.warn(
105 'cannot resolve public {} address, looking for another '
106 'candidate: {}'.format(value, error))
107 if fallback_address is not None:
108 # If we already have a fallback address then we can jump to the
109 # next candidate.
110 continue
111 address_type = address.get('Type')
112 if scope == SCOPE_CLOUD_LOCAL and address_type == IPV4_ADDRESS:
113 # Use the local cloud scoped address as fallback value: the
114 # current environment is probably maas and the public address
115 # of the instance is not resolvable by local DNS.
116 fallback_address = value
117 elif scope == SCOPE_UNKNOWN:
118 # Use the unknown scoped address as fallback value: the current
119 # environment is probably local.
120 fallback_address = value
121 return fallback_address
93122
94123
95def parse_machine_change(action, data, current_status, address):124def parse_machine_change(action, data, current_status, address):
@@ -120,7 +149,8 @@
120 # of units hosted by a specific machine.149 # of units hosted by a specific machine.
121 if not address:150 if not address:
122 addresses = data.get('Addresses', [])151 addresses = data.get('Addresses', [])
123 public_address = retrieve_public_adddress(addresses)152 public_address = retrieve_public_adddress(
153 addresses, utils.check_resolvable)
124 if public_address is not None:154 if public_address is not None:
125 address = public_address155 address = public_address
126 print('unit placed on {}'.format(address))156 print('unit placed on {}'.format(address))
@@ -134,16 +164,16 @@
134 return status, address164 return status, address
135165
136166
137def parse_unit_change(action, data, current_status, address):167def parse_unit_change(action, data, current_status):
138 """Parse the given unit change.168 """Parse the given unit change.
139169
140 The change is represented by the given action/data pair.170 The change is represented by the given action/data pair.
141 Also receive the last known unit status and address, which can be empty171 Also receive the last known unit status which can be empty if the unit
142 strings if those pieces of information are unknown.172 status is not yet known.
143173
144 Output a human readable message each time a relevant change is found.174 Output a human readable message each time a relevant change is found.
145175
146 Return the unit status, address and machine identifier.176 Return the unit status and the identifier of the machine hosting the unit.
147 Raise a ValueError if the service unit is removed or in an error state.177 Raise a ValueError if the service unit is removed or in an error state.
148 """178 """
149 unit_name = data['Name']179 unit_name = data['Name']
@@ -157,15 +187,6 @@
157 msg = '{} is in an error state: {}: {}'.format(187 msg = '{} is in an error state: {}: {}'.format(
158 unit_name, status, data['StatusInfo'])188 unit_name, status, data['StatusInfo'])
159 raise ValueError(msg.encode('utf-8'))189 raise ValueError(msg.encode('utf-8'))
160 # Notify when the unit becomes reachable. Up to juju-core 1.18, the
161 # mega-watcher for units includes the public address for each unit. This
162 # info is likely to be deprecated in favor of addresses as included in the
163 # mega-watcher for machines, but we still try to retrieve the address here
164 # for backward compatibility.
165 if not address:
166 address = data.get('PublicAddress', '')
167 if address:
168 print('{} placed on {}'.format(unit_name, address))
169 # Notify status changes.190 # Notify status changes.
170 if status != current_status:191 if status != current_status:
171 if status == 'pending':192 if status == 'pending':
@@ -175,7 +196,7 @@
175 elif status == 'started':196 elif status == 'started':
176 print('{} is ready on machine {}'.format(197 print('{} is ready on machine {}'.format(
177 unit_name, data['MachineId']))198 unit_name, data['MachineId']))
178 return status, address, data.get('MachineId', '')199 return status, data.get('MachineId', '')
179200
180201
181def unit_machine_changes(changeset):202def unit_machine_changes(changeset):

Subscribers

People subscribed via source and target branches