Merge lp:~gz/juju-ci-tools/substrate_maas2 into lp:juju-ci-tools

Proposed by Martin Packman
Status: Merged
Approved by: Martin Packman
Approved revision: 1385
Merged at revision: 1385
Proposed branch: lp:~gz/juju-ci-tools/substrate_maas2
Merge into: lp:juju-ci-tools
Prerequisite: lp:~gz/juju-ci-tools/substrate_logging
Diff against target: 386 lines (+208/-52)
2 files modified
substrate.py (+45/-18)
tests/test_substrate.py (+163/-34)
To merge this branch: bzr merge lp:~gz/juju-ci-tools/substrate_maas2
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+293175@code.launchpad.net

Description of the change

Add MAAS 2.0 support in substrate

There are lots of different ways of doing this, the most elegant seemed a subclass for the 'old' api like we do with juju itself. The version selection is unfortunately a little ugly as I can't find a better way of doing it than trying login and swallowing the (unhelpful) failure and trying the other version.

This means I've broken the common pattern of having a usable manager_from_config classmethod, but MAAS was already special cased in practice and the alternatives seemed worse.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you.

review: Approve (code)
lp:~gz/juju-ci-tools/substrate_maas2 updated
1385. By Martin Packman

Correct check on MAASAccount types in test

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'substrate.py'
--- substrate.py 2016-04-27 19:59:40 +0000
+++ substrate.py 2016-04-27 21:59:07 +0000
@@ -54,7 +54,7 @@
54 environ.update(translate_to_env(env.config))54 environ.update(translate_to_env(env.config))
55 command_args = ['nova', 'delete'] + instance_ids55 command_args = ['nova', 'delete'] + instance_ids
56 elif provider_type == 'maas':56 elif provider_type == 'maas':
57 with MAASAccount.manager_from_config(env.config) as substrate:57 with maas_account_from_config(env.config) as substrate:
58 substrate.terminate_instances(instance_ids)58 substrate.terminate_instances(instance_ids)
59 return59 return
60 elif provider_type == 'lxd':60 elif provider_type == 'lxd':
@@ -362,23 +362,15 @@
362362
363363
364class MAASAccount:364class MAASAccount:
365 """Represent a Mass account."""365 """Represent a MAAS 2.0 account."""
366
367 _API_PATH = 'api/2.0/'
366368
367 def __init__(self, profile, url, oauth):369 def __init__(self, profile, url, oauth):
368 self.profile = profile370 self.profile = profile
369 self.url = urlparse.urljoin(url, 'api/1.0/')371 self.url = urlparse.urljoin(url, self._API_PATH)
370 self.oauth = oauth372 self.oauth = oauth
371373
372 @classmethod
373 @contextmanager
374 def manager_from_config(cls, config):
375 """Create a ContextManager for a MaasAccount."""
376 manager = cls(
377 config['name'], config['maas-server'], config['maas-oauth'])
378 manager.login()
379 yield manager
380 manager.logout()
381
382 def login(self):374 def login(self):
383 """Login with the maas cli."""375 """Login with the maas cli."""
384 subprocess.check_call(376 subprocess.check_call(
@@ -389,18 +381,22 @@
389 subprocess.check_call(381 subprocess.check_call(
390 ['maas', 'logout', self.profile])382 ['maas', 'logout', self.profile])
391383
384 def _machine_release_args(self, machine_id):
385 return ['maas', self.profile, 'machine', 'release', machine_id]
386
392 def terminate_instances(self, instance_ids):387 def terminate_instances(self, instance_ids):
393 """Terminate the specified instances."""388 """Terminate the specified instances."""
394 for instance in instance_ids:389 for instance in instance_ids:
395 maas_system_id = instance.split('/')[5]390 maas_system_id = instance.split('/')[5]
396 log.info('Deleting %s.' % instance)391 log.info('Deleting %s.' % instance)
397 subprocess.check_call(392 subprocess.check_call(self._machine_release_args(maas_system_id))
398 ['maas', self.profile, 'node', 'release', maas_system_id])393
394 def _list_allocated_args(self):
395 return ['maas', self.profile, 'machines', 'list-allocated']
399396
400 def get_allocated_nodes(self):397 def get_allocated_nodes(self):
401 """Return a dict of allocated nodes with the hostname as keys."""398 """Return a dict of allocated nodes with the hostname as keys."""
402 data = subprocess.check_output(399 data = subprocess.check_output(self._list_allocated_args())
403 ['maas', self.profile, 'nodes', 'list-allocated'])
404 nodes = json.loads(data)400 nodes = json.loads(data)
405 allocated = {node['hostname']: node for node in nodes}401 allocated = {node['hostname']: node for node in nodes}
406 return allocated402 return allocated
@@ -417,6 +413,37 @@
417 return ips413 return ips
418414
419415
416class MAAS1Account(MAASAccount):
417 """Represent a MAAS 1.X account."""
418
419 _API_PATH = 'api/1.0/'
420
421 def _list_allocated_args(self):
422 return ['maas', self.profile, 'nodes', 'list-allocated']
423
424 def _machine_release_args(self, machine_id):
425 return ['maas', self.profile, 'node', 'release', machine_id]
426
427
428@contextmanager
429def maas_account_from_config(config):
430 """Create a ContextManager for either a MAASAccount or a MAAS1Account.
431
432 As it's not possible to tell from the maas config which version of the api
433 to use, try 2.0 and if that fails on login fallback to 1.0 instead.
434 """
435 args = (config['name'], config['maas-server'], config['maas-oauth'])
436 manager = MAASAccount(*args)
437 try:
438 manager.login()
439 except subprocess.CalledProcessError:
440 log.info("Could not login with MAAS 2.0 API, trying 1.0")
441 manager = MAAS1Account(*args)
442 manager.login()
443 yield manager
444 manager.logout()
445
446
420class LXDAccount:447class LXDAccount:
421 """Represent a LXD account."""448 """Represent a LXD account."""
422449
@@ -602,7 +629,7 @@
602 # Only MAAS requires special handling at prsent.629 # Only MAAS requires special handling at prsent.
603 return630 return
604 # MAAS hostnames are not resolvable, but we can adapt them to IPs.631 # MAAS hostnames are not resolvable, but we can adapt them to IPs.
605 with MAASAccount.manager_from_config(env.config) as account:632 with maas_account_from_config(env.config) as account:
606 allocated_ips = account.get_allocated_ips()633 allocated_ips = account.get_allocated_ips()
607 for remote in remote_machines:634 for remote in remote_machines:
608 if remote.get_address() in allocated_ips:635 if remote.get_address() in allocated_ips:
609636
=== modified file 'tests/test_substrate.py'
--- tests/test_substrate.py 2016-04-27 19:59:40 +0000
+++ tests/test_substrate.py 2016-04-27 21:59:07 +0000
@@ -31,6 +31,8 @@
31 LXDAccount,31 LXDAccount,
32 make_substrate_manager,32 make_substrate_manager,
33 MAASAccount,33 MAASAccount,
34 MAAS1Account,
35 maas_account_from_config,
34 OpenStackAccount,36 OpenStackAccount,
35 parse_euca,37 parse_euca,
36 run_instances,38 run_instances,
@@ -159,11 +161,11 @@
159 with patch('subprocess.check_call') as cc_mock:161 with patch('subprocess.check_call') as cc_mock:
160 terminate_instances(env, ['/A/B/C/D/node-3d/'])162 terminate_instances(env, ['/A/B/C/D/node-3d/'])
161 expected = (163 expected = (
162 ['maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/1.0/',164 ['maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/2.0/',
163 'a:password:string'],165 'a:password:string'],
164 )166 )
165 self.assertEqual(expected, cc_mock.call_args_list[0][0])167 self.assertEqual(expected, cc_mock.call_args_list[0][0])
166 expected = (['maas', 'mas', 'node', 'release', 'node-3d'],)168 expected = (['maas', 'mas', 'machine', 'release', 'node-3d'],)
167 self.assertEqual(expected, cc_mock.call_args_list[1][0])169 self.assertEqual(expected, cc_mock.call_args_list[1][0])
168 expected = (['maas', 'logout', 'mas'],)170 expected = (['maas', 'logout', 'mas'],)
169 self.assertEqual(expected, cc_mock.call_args_list[2][0])171 self.assertEqual(expected, cc_mock.call_args_list[2][0])
@@ -674,25 +676,85 @@
674 client.delete_hosted_service.assert_called_once_with('foo')676 client.delete_hosted_service.assert_called_once_with('foo')
675677
676678
677class TestMAASAcount(TestCase):679class TestMAASAccount(TestCase):
678680
679 @patch.object(MAASAccount, 'logout', autospec=True)681 @patch('subprocess.check_call', autospec=True)
680 @patch.object(MAASAccount, 'login', autospec=True)682 def test_login(self, cc_mock):
681 def test_manager_from_config(self, li_mock, lo_mock):683 config = get_maas_env().config
682 config = get_maas_env().config684 account = MAASAccount(
683 with MAASAccount.manager_from_config(config) as account:685 config['name'], config['maas-server'], config['maas-oauth'])
684 self.assertEqual(account.profile, 'mas')686 account.login()
685 self.assertEqual(account.url, 'http://10.0.10.10/MAAS/api/1.0/')687 cc_mock.assert_called_once_with([
686 self.assertEqual(account.oauth, 'a:password:string')688 'maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/2.0/',
687 # As the class object is patched, the mocked methods689 'a:password:string'])
688 # show that self is passed.690
689 li_mock.assert_called_once_with(account)691 @patch('subprocess.check_call', autospec=True)
690 lo_mock.assert_called_once_with(account)692 def test_logout(self, cc_mock):
691693 config = get_maas_env().config
692 @patch('subprocess.check_call', autospec=True)694 account = MAASAccount(
693 def test_login(self, cc_mock):695 config['name'], config['maas-server'], config['maas-oauth'])
694 config = get_maas_env().config696 account.logout()
695 account = MAASAccount(697 cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
698
699 @patch('subprocess.check_call', autospec=True)
700 def test_terminate_instances(self, cc_mock):
701 config = get_maas_env().config
702 account = MAASAccount(
703 config['name'], config['maas-server'], config['maas-oauth'])
704 instance_ids = ['/A/B/C/D/node-1d/', '/A/B/C/D/node-2d/']
705 account.terminate_instances(instance_ids)
706 cc_mock.assert_any_call(
707 ['maas', 'mas', 'machine', 'release', 'node-1d'])
708 cc_mock.assert_called_with(
709 ['maas', 'mas', 'machine', 'release', 'node-2d'])
710
711 def test_get_allocated_nodes(self):
712 config = get_maas_env().config
713 account = MAASAccount(
714 config['name'], config['maas-server'], config['maas-oauth'])
715 node = make_maas_node('maas-node-1.maas')
716 allocated_nodes_string = '[%s]' % json.dumps(node)
717 with patch('subprocess.check_output', autospec=True,
718 return_value=allocated_nodes_string) as co_mock:
719 allocated = account.get_allocated_nodes()
720 co_mock.assert_called_once_with(
721 ['maas', 'mas', 'machines', 'list-allocated'])
722 self.assertEqual(node, allocated['maas-node-1.maas'])
723
724 def test_get_allocated_ips(self):
725 config = get_maas_env().config
726 account = MAASAccount(
727 config['name'], config['maas-server'], config['maas-oauth'])
728 node = make_maas_node('maas-node-1.maas')
729 allocated_nodes_string = '[%s]' % json.dumps(node)
730 with patch('subprocess.check_output', autospec=True,
731 return_value=allocated_nodes_string) as co_mock:
732 ips = account.get_allocated_ips()
733 co_mock.assert_called_once_with(
734 ['maas', 'mas', 'machines', 'list-allocated'])
735 self.assertEqual('10.0.30.165', ips['maas-node-1.maas'])
736
737 def test_get_allocated_ips_empty(self):
738 config = get_maas_env().config
739 account = MAASAccount(
740 config['name'], config['maas-server'], config['maas-oauth'])
741 node = make_maas_node('maas-node-1.maas')
742 node['ip_addresses'] = []
743 allocated_nodes_string = '[%s]' % json.dumps(node)
744 with patch('subprocess.check_output', autospec=True,
745 return_value=allocated_nodes_string) as co_mock:
746 ips = account.get_allocated_ips()
747 co_mock.assert_called_once_with(
748 ['maas', 'mas', 'machines', 'list-allocated'])
749 self.assertEqual({}, ips)
750
751
752class TestMAAS1Account(TestCase):
753
754 @patch('subprocess.check_call', autospec=True)
755 def test_login(self, cc_mock):
756 config = get_maas_env().config
757 account = MAAS1Account(
696 config['name'], config['maas-server'], config['maas-oauth'])758 config['name'], config['maas-server'], config['maas-oauth'])
697 account.login()759 account.login()
698 cc_mock.assert_called_once_with([760 cc_mock.assert_called_once_with([
@@ -702,7 +764,7 @@
702 @patch('subprocess.check_call', autospec=True)764 @patch('subprocess.check_call', autospec=True)
703 def test_logout(self, cc_mock):765 def test_logout(self, cc_mock):
704 config = get_maas_env().config766 config = get_maas_env().config
705 account = MAASAccount(767 account = MAAS1Account(
706 config['name'], config['maas-server'], config['maas-oauth'])768 config['name'], config['maas-server'], config['maas-oauth'])
707 account.logout()769 account.logout()
708 cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])770 cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
@@ -710,7 +772,7 @@
710 @patch('subprocess.check_call', autospec=True)772 @patch('subprocess.check_call', autospec=True)
711 def test_terminate_instances(self, cc_mock):773 def test_terminate_instances(self, cc_mock):
712 config = get_maas_env().config774 config = get_maas_env().config
713 account = MAASAccount(775 account = MAAS1Account(
714 config['name'], config['maas-server'], config['maas-oauth'])776 config['name'], config['maas-server'], config['maas-oauth'])
715 instance_ids = ['/A/B/C/D/node-1d/', '/A/B/C/D/node-2d/']777 instance_ids = ['/A/B/C/D/node-1d/', '/A/B/C/D/node-2d/']
716 account.terminate_instances(instance_ids)778 account.terminate_instances(instance_ids)
@@ -719,10 +781,9 @@
719 cc_mock.assert_called_with(781 cc_mock.assert_called_with(
720 ['maas', 'mas', 'node', 'release', 'node-2d'])782 ['maas', 'mas', 'node', 'release', 'node-2d'])
721783
722 @patch('subprocess.check_call', autospec=True)784 def test_get_allocated_nodes(self):
723 def test_get_allocated_nodes(self, cc_mock):
724 config = get_maas_env().config785 config = get_maas_env().config
725 account = MAASAccount(786 account = MAAS1Account(
726 config['name'], config['maas-server'], config['maas-oauth'])787 config['name'], config['maas-server'], config['maas-oauth'])
727 node = make_maas_node('maas-node-1.maas')788 node = make_maas_node('maas-node-1.maas')
728 allocated_nodes_string = '[%s]' % json.dumps(node)789 allocated_nodes_string = '[%s]' % json.dumps(node)
@@ -733,32 +794,100 @@
733 ['maas', 'mas', 'nodes', 'list-allocated'])794 ['maas', 'mas', 'nodes', 'list-allocated'])
734 self.assertEqual(node, allocated['maas-node-1.maas'])795 self.assertEqual(node, allocated['maas-node-1.maas'])
735796
736 @patch('subprocess.check_call', autospec=True)797 def test_get_allocated_ips(self):
737 def test_get_allocated_ips(self, cc_mock):
738 config = get_maas_env().config798 config = get_maas_env().config
739 account = MAASAccount(799 account = MAAS1Account(
740 config['name'], config['maas-server'], config['maas-oauth'])800 config['name'], config['maas-server'], config['maas-oauth'])
741 node = make_maas_node('maas-node-1.maas')801 node = make_maas_node('maas-node-1.maas')
742 allocated_nodes_string = '[%s]' % json.dumps(node)802 allocated_nodes_string = '[%s]' % json.dumps(node)
743 with patch('subprocess.check_output', autospec=True,803 with patch('subprocess.check_output', autospec=True,
744 return_value=allocated_nodes_string):804 return_value=allocated_nodes_string) as co_mock:
745 ips = account.get_allocated_ips()805 ips = account.get_allocated_ips()
806 co_mock.assert_called_once_with(
807 ['maas', 'mas', 'nodes', 'list-allocated'])
746 self.assertEqual('10.0.30.165', ips['maas-node-1.maas'])808 self.assertEqual('10.0.30.165', ips['maas-node-1.maas'])
747809
748 @patch('subprocess.check_call', autospec=True)810 def test_get_allocated_ips_empty(self):
749 def test_get_allocated_ips_empty(self, cc_mock):
750 config = get_maas_env().config811 config = get_maas_env().config
751 account = MAASAccount(812 account = MAAS1Account(
752 config['name'], config['maas-server'], config['maas-oauth'])813 config['name'], config['maas-server'], config['maas-oauth'])
753 node = make_maas_node('maas-node-1.maas')814 node = make_maas_node('maas-node-1.maas')
754 node['ip_addresses'] = []815 node['ip_addresses'] = []
755 allocated_nodes_string = '[%s]' % json.dumps(node)816 allocated_nodes_string = '[%s]' % json.dumps(node)
756 with patch('subprocess.check_output', autospec=True,817 with patch('subprocess.check_output', autospec=True,
757 return_value=allocated_nodes_string):818 return_value=allocated_nodes_string) as co_mock:
758 ips = account.get_allocated_ips()819 ips = account.get_allocated_ips()
820 co_mock.assert_called_once_with(
821 ['maas', 'mas', 'nodes', 'list-allocated'])
759 self.assertEqual({}, ips)822 self.assertEqual({}, ips)
760823
761824
825class TestMAASAccountFromConfig(TestCase):
826
827 def test_login_succeeds(self):
828 config = get_maas_env().config
829 with patch('subprocess.check_call', autospec=True) as cc_mock:
830 with maas_account_from_config(config) as maas:
831 self.assertIs(type(maas), MAASAccount)
832 self.assertEqual(maas.profile, 'mas')
833 self.assertEqual(maas.url, 'http://10.0.10.10/MAAS/api/2.0/')
834 self.assertEqual(maas.oauth, 'a:password:string')
835 # The login call has happened on context manager enter, reset
836 # the mock after to verify the logout call.
837 cc_mock.assert_called_once_with([
838 'maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/2.0/',
839 'a:password:string'])
840 cc_mock.reset_mock()
841 cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
842
843 def test_login_fallback(self):
844 config = get_maas_env().config
845 login_error = CalledProcessError(1, ['maas', 'login'])
846 with patch('subprocess.check_call', autospec=True,
847 side_effect=[login_error, None, None]) as cc_mock:
848 with maas_account_from_config(config) as maas:
849 self.assertIs(type(maas), MAAS1Account)
850 self.assertEqual(maas.profile, 'mas')
851 self.assertEqual(maas.url, 'http://10.0.10.10/MAAS/api/1.0/')
852 self.assertEqual(maas.oauth, 'a:password:string')
853 # The first login attempt was with the 2.0 api, after which
854 # a 1.0 login succeeded.
855 self.assertEquals(cc_mock.call_args_list, [
856 call(['maas', 'login', 'mas',
857 'http://10.0.10.10/MAAS/api/2.0/',
858 'a:password:string']),
859 call(['maas', 'login', 'mas',
860 'http://10.0.10.10/MAAS/api/1.0/',
861 'a:password:string']),
862 ])
863 cc_mock.reset_mock()
864 cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
865 self.assertEqual(
866 self.log_stream.getvalue(),
867 'INFO Could not login with MAAS 2.0 API, trying 1.0\n')
868
869 def test_login_both_fail(self):
870 config = get_maas_env().config
871 login_error = CalledProcessError(1, ['maas', 'login'])
872 with patch('subprocess.check_call', autospec=True,
873 side_effect=login_error) as cc_mock:
874 with self.assertRaises(CalledProcessError) as ctx:
875 with maas_account_from_config(config):
876 self.fail('Should never get manager with failed login')
877 self.assertIs(ctx.exception, login_error)
878 self.assertEquals(cc_mock.call_args_list, [
879 call(['maas', 'login', 'mas',
880 'http://10.0.10.10/MAAS/api/2.0/',
881 'a:password:string']),
882 call(['maas', 'login', 'mas',
883 'http://10.0.10.10/MAAS/api/1.0/',
884 'a:password:string']),
885 ])
886 self.assertEqual(
887 self.log_stream.getvalue(),
888 'INFO Could not login with MAAS 2.0 API, trying 1.0\n')
889
890
762class TestMakeSubstrateManager(TestCase):891class TestMakeSubstrateManager(TestCase):
763892
764 def test_make_substrate_manager_aws(self):893 def test_make_substrate_manager_aws(self):

Subscribers

People subscribed via source and target branches