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
1=== modified file 'substrate.py'
2--- substrate.py 2016-04-27 19:59:40 +0000
3+++ substrate.py 2016-04-27 21:59:07 +0000
4@@ -54,7 +54,7 @@
5 environ.update(translate_to_env(env.config))
6 command_args = ['nova', 'delete'] + instance_ids
7 elif provider_type == 'maas':
8- with MAASAccount.manager_from_config(env.config) as substrate:
9+ with maas_account_from_config(env.config) as substrate:
10 substrate.terminate_instances(instance_ids)
11 return
12 elif provider_type == 'lxd':
13@@ -362,23 +362,15 @@
14
15
16 class MAASAccount:
17- """Represent a Mass account."""
18+ """Represent a MAAS 2.0 account."""
19+
20+ _API_PATH = 'api/2.0/'
21
22 def __init__(self, profile, url, oauth):
23 self.profile = profile
24- self.url = urlparse.urljoin(url, 'api/1.0/')
25+ self.url = urlparse.urljoin(url, self._API_PATH)
26 self.oauth = oauth
27
28- @classmethod
29- @contextmanager
30- def manager_from_config(cls, config):
31- """Create a ContextManager for a MaasAccount."""
32- manager = cls(
33- config['name'], config['maas-server'], config['maas-oauth'])
34- manager.login()
35- yield manager
36- manager.logout()
37-
38 def login(self):
39 """Login with the maas cli."""
40 subprocess.check_call(
41@@ -389,18 +381,22 @@
42 subprocess.check_call(
43 ['maas', 'logout', self.profile])
44
45+ def _machine_release_args(self, machine_id):
46+ return ['maas', self.profile, 'machine', 'release', machine_id]
47+
48 def terminate_instances(self, instance_ids):
49 """Terminate the specified instances."""
50 for instance in instance_ids:
51 maas_system_id = instance.split('/')[5]
52 log.info('Deleting %s.' % instance)
53- subprocess.check_call(
54- ['maas', self.profile, 'node', 'release', maas_system_id])
55+ subprocess.check_call(self._machine_release_args(maas_system_id))
56+
57+ def _list_allocated_args(self):
58+ return ['maas', self.profile, 'machines', 'list-allocated']
59
60 def get_allocated_nodes(self):
61 """Return a dict of allocated nodes with the hostname as keys."""
62- data = subprocess.check_output(
63- ['maas', self.profile, 'nodes', 'list-allocated'])
64+ data = subprocess.check_output(self._list_allocated_args())
65 nodes = json.loads(data)
66 allocated = {node['hostname']: node for node in nodes}
67 return allocated
68@@ -417,6 +413,37 @@
69 return ips
70
71
72+class MAAS1Account(MAASAccount):
73+ """Represent a MAAS 1.X account."""
74+
75+ _API_PATH = 'api/1.0/'
76+
77+ def _list_allocated_args(self):
78+ return ['maas', self.profile, 'nodes', 'list-allocated']
79+
80+ def _machine_release_args(self, machine_id):
81+ return ['maas', self.profile, 'node', 'release', machine_id]
82+
83+
84+@contextmanager
85+def maas_account_from_config(config):
86+ """Create a ContextManager for either a MAASAccount or a MAAS1Account.
87+
88+ As it's not possible to tell from the maas config which version of the api
89+ to use, try 2.0 and if that fails on login fallback to 1.0 instead.
90+ """
91+ args = (config['name'], config['maas-server'], config['maas-oauth'])
92+ manager = MAASAccount(*args)
93+ try:
94+ manager.login()
95+ except subprocess.CalledProcessError:
96+ log.info("Could not login with MAAS 2.0 API, trying 1.0")
97+ manager = MAAS1Account(*args)
98+ manager.login()
99+ yield manager
100+ manager.logout()
101+
102+
103 class LXDAccount:
104 """Represent a LXD account."""
105
106@@ -602,7 +629,7 @@
107 # Only MAAS requires special handling at prsent.
108 return
109 # MAAS hostnames are not resolvable, but we can adapt them to IPs.
110- with MAASAccount.manager_from_config(env.config) as account:
111+ with maas_account_from_config(env.config) as account:
112 allocated_ips = account.get_allocated_ips()
113 for remote in remote_machines:
114 if remote.get_address() in allocated_ips:
115
116=== modified file 'tests/test_substrate.py'
117--- tests/test_substrate.py 2016-04-27 19:59:40 +0000
118+++ tests/test_substrate.py 2016-04-27 21:59:07 +0000
119@@ -31,6 +31,8 @@
120 LXDAccount,
121 make_substrate_manager,
122 MAASAccount,
123+ MAAS1Account,
124+ maas_account_from_config,
125 OpenStackAccount,
126 parse_euca,
127 run_instances,
128@@ -159,11 +161,11 @@
129 with patch('subprocess.check_call') as cc_mock:
130 terminate_instances(env, ['/A/B/C/D/node-3d/'])
131 expected = (
132- ['maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/1.0/',
133+ ['maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/2.0/',
134 'a:password:string'],
135 )
136 self.assertEqual(expected, cc_mock.call_args_list[0][0])
137- expected = (['maas', 'mas', 'node', 'release', 'node-3d'],)
138+ expected = (['maas', 'mas', 'machine', 'release', 'node-3d'],)
139 self.assertEqual(expected, cc_mock.call_args_list[1][0])
140 expected = (['maas', 'logout', 'mas'],)
141 self.assertEqual(expected, cc_mock.call_args_list[2][0])
142@@ -674,25 +676,85 @@
143 client.delete_hosted_service.assert_called_once_with('foo')
144
145
146-class TestMAASAcount(TestCase):
147-
148- @patch.object(MAASAccount, 'logout', autospec=True)
149- @patch.object(MAASAccount, 'login', autospec=True)
150- def test_manager_from_config(self, li_mock, lo_mock):
151- config = get_maas_env().config
152- with MAASAccount.manager_from_config(config) as account:
153- self.assertEqual(account.profile, 'mas')
154- self.assertEqual(account.url, 'http://10.0.10.10/MAAS/api/1.0/')
155- self.assertEqual(account.oauth, 'a:password:string')
156- # As the class object is patched, the mocked methods
157- # show that self is passed.
158- li_mock.assert_called_once_with(account)
159- lo_mock.assert_called_once_with(account)
160-
161- @patch('subprocess.check_call', autospec=True)
162- def test_login(self, cc_mock):
163- config = get_maas_env().config
164- account = MAASAccount(
165+class TestMAASAccount(TestCase):
166+
167+ @patch('subprocess.check_call', autospec=True)
168+ def test_login(self, cc_mock):
169+ config = get_maas_env().config
170+ account = MAASAccount(
171+ config['name'], config['maas-server'], config['maas-oauth'])
172+ account.login()
173+ cc_mock.assert_called_once_with([
174+ 'maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/2.0/',
175+ 'a:password:string'])
176+
177+ @patch('subprocess.check_call', autospec=True)
178+ def test_logout(self, cc_mock):
179+ config = get_maas_env().config
180+ account = MAASAccount(
181+ config['name'], config['maas-server'], config['maas-oauth'])
182+ account.logout()
183+ cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
184+
185+ @patch('subprocess.check_call', autospec=True)
186+ def test_terminate_instances(self, cc_mock):
187+ config = get_maas_env().config
188+ account = MAASAccount(
189+ config['name'], config['maas-server'], config['maas-oauth'])
190+ instance_ids = ['/A/B/C/D/node-1d/', '/A/B/C/D/node-2d/']
191+ account.terminate_instances(instance_ids)
192+ cc_mock.assert_any_call(
193+ ['maas', 'mas', 'machine', 'release', 'node-1d'])
194+ cc_mock.assert_called_with(
195+ ['maas', 'mas', 'machine', 'release', 'node-2d'])
196+
197+ def test_get_allocated_nodes(self):
198+ config = get_maas_env().config
199+ account = MAASAccount(
200+ config['name'], config['maas-server'], config['maas-oauth'])
201+ node = make_maas_node('maas-node-1.maas')
202+ allocated_nodes_string = '[%s]' % json.dumps(node)
203+ with patch('subprocess.check_output', autospec=True,
204+ return_value=allocated_nodes_string) as co_mock:
205+ allocated = account.get_allocated_nodes()
206+ co_mock.assert_called_once_with(
207+ ['maas', 'mas', 'machines', 'list-allocated'])
208+ self.assertEqual(node, allocated['maas-node-1.maas'])
209+
210+ def test_get_allocated_ips(self):
211+ config = get_maas_env().config
212+ account = MAASAccount(
213+ config['name'], config['maas-server'], config['maas-oauth'])
214+ node = make_maas_node('maas-node-1.maas')
215+ allocated_nodes_string = '[%s]' % json.dumps(node)
216+ with patch('subprocess.check_output', autospec=True,
217+ return_value=allocated_nodes_string) as co_mock:
218+ ips = account.get_allocated_ips()
219+ co_mock.assert_called_once_with(
220+ ['maas', 'mas', 'machines', 'list-allocated'])
221+ self.assertEqual('10.0.30.165', ips['maas-node-1.maas'])
222+
223+ def test_get_allocated_ips_empty(self):
224+ config = get_maas_env().config
225+ account = MAASAccount(
226+ config['name'], config['maas-server'], config['maas-oauth'])
227+ node = make_maas_node('maas-node-1.maas')
228+ node['ip_addresses'] = []
229+ allocated_nodes_string = '[%s]' % json.dumps(node)
230+ with patch('subprocess.check_output', autospec=True,
231+ return_value=allocated_nodes_string) as co_mock:
232+ ips = account.get_allocated_ips()
233+ co_mock.assert_called_once_with(
234+ ['maas', 'mas', 'machines', 'list-allocated'])
235+ self.assertEqual({}, ips)
236+
237+
238+class TestMAAS1Account(TestCase):
239+
240+ @patch('subprocess.check_call', autospec=True)
241+ def test_login(self, cc_mock):
242+ config = get_maas_env().config
243+ account = MAAS1Account(
244 config['name'], config['maas-server'], config['maas-oauth'])
245 account.login()
246 cc_mock.assert_called_once_with([
247@@ -702,7 +764,7 @@
248 @patch('subprocess.check_call', autospec=True)
249 def test_logout(self, cc_mock):
250 config = get_maas_env().config
251- account = MAASAccount(
252+ account = MAAS1Account(
253 config['name'], config['maas-server'], config['maas-oauth'])
254 account.logout()
255 cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
256@@ -710,7 +772,7 @@
257 @patch('subprocess.check_call', autospec=True)
258 def test_terminate_instances(self, cc_mock):
259 config = get_maas_env().config
260- account = MAASAccount(
261+ account = MAAS1Account(
262 config['name'], config['maas-server'], config['maas-oauth'])
263 instance_ids = ['/A/B/C/D/node-1d/', '/A/B/C/D/node-2d/']
264 account.terminate_instances(instance_ids)
265@@ -719,10 +781,9 @@
266 cc_mock.assert_called_with(
267 ['maas', 'mas', 'node', 'release', 'node-2d'])
268
269- @patch('subprocess.check_call', autospec=True)
270- def test_get_allocated_nodes(self, cc_mock):
271+ def test_get_allocated_nodes(self):
272 config = get_maas_env().config
273- account = MAASAccount(
274+ account = MAAS1Account(
275 config['name'], config['maas-server'], config['maas-oauth'])
276 node = make_maas_node('maas-node-1.maas')
277 allocated_nodes_string = '[%s]' % json.dumps(node)
278@@ -733,32 +794,100 @@
279 ['maas', 'mas', 'nodes', 'list-allocated'])
280 self.assertEqual(node, allocated['maas-node-1.maas'])
281
282- @patch('subprocess.check_call', autospec=True)
283- def test_get_allocated_ips(self, cc_mock):
284+ def test_get_allocated_ips(self):
285 config = get_maas_env().config
286- account = MAASAccount(
287+ account = MAAS1Account(
288 config['name'], config['maas-server'], config['maas-oauth'])
289 node = make_maas_node('maas-node-1.maas')
290 allocated_nodes_string = '[%s]' % json.dumps(node)
291 with patch('subprocess.check_output', autospec=True,
292- return_value=allocated_nodes_string):
293+ return_value=allocated_nodes_string) as co_mock:
294 ips = account.get_allocated_ips()
295+ co_mock.assert_called_once_with(
296+ ['maas', 'mas', 'nodes', 'list-allocated'])
297 self.assertEqual('10.0.30.165', ips['maas-node-1.maas'])
298
299- @patch('subprocess.check_call', autospec=True)
300- def test_get_allocated_ips_empty(self, cc_mock):
301+ def test_get_allocated_ips_empty(self):
302 config = get_maas_env().config
303- account = MAASAccount(
304+ account = MAAS1Account(
305 config['name'], config['maas-server'], config['maas-oauth'])
306 node = make_maas_node('maas-node-1.maas')
307 node['ip_addresses'] = []
308 allocated_nodes_string = '[%s]' % json.dumps(node)
309 with patch('subprocess.check_output', autospec=True,
310- return_value=allocated_nodes_string):
311+ return_value=allocated_nodes_string) as co_mock:
312 ips = account.get_allocated_ips()
313+ co_mock.assert_called_once_with(
314+ ['maas', 'mas', 'nodes', 'list-allocated'])
315 self.assertEqual({}, ips)
316
317
318+class TestMAASAccountFromConfig(TestCase):
319+
320+ def test_login_succeeds(self):
321+ config = get_maas_env().config
322+ with patch('subprocess.check_call', autospec=True) as cc_mock:
323+ with maas_account_from_config(config) as maas:
324+ self.assertIs(type(maas), MAASAccount)
325+ self.assertEqual(maas.profile, 'mas')
326+ self.assertEqual(maas.url, 'http://10.0.10.10/MAAS/api/2.0/')
327+ self.assertEqual(maas.oauth, 'a:password:string')
328+ # The login call has happened on context manager enter, reset
329+ # the mock after to verify the logout call.
330+ cc_mock.assert_called_once_with([
331+ 'maas', 'login', 'mas', 'http://10.0.10.10/MAAS/api/2.0/',
332+ 'a:password:string'])
333+ cc_mock.reset_mock()
334+ cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
335+
336+ def test_login_fallback(self):
337+ config = get_maas_env().config
338+ login_error = CalledProcessError(1, ['maas', 'login'])
339+ with patch('subprocess.check_call', autospec=True,
340+ side_effect=[login_error, None, None]) as cc_mock:
341+ with maas_account_from_config(config) as maas:
342+ self.assertIs(type(maas), MAAS1Account)
343+ self.assertEqual(maas.profile, 'mas')
344+ self.assertEqual(maas.url, 'http://10.0.10.10/MAAS/api/1.0/')
345+ self.assertEqual(maas.oauth, 'a:password:string')
346+ # The first login attempt was with the 2.0 api, after which
347+ # a 1.0 login succeeded.
348+ self.assertEquals(cc_mock.call_args_list, [
349+ call(['maas', 'login', 'mas',
350+ 'http://10.0.10.10/MAAS/api/2.0/',
351+ 'a:password:string']),
352+ call(['maas', 'login', 'mas',
353+ 'http://10.0.10.10/MAAS/api/1.0/',
354+ 'a:password:string']),
355+ ])
356+ cc_mock.reset_mock()
357+ cc_mock.assert_called_once_with(['maas', 'logout', 'mas'])
358+ self.assertEqual(
359+ self.log_stream.getvalue(),
360+ 'INFO Could not login with MAAS 2.0 API, trying 1.0\n')
361+
362+ def test_login_both_fail(self):
363+ config = get_maas_env().config
364+ login_error = CalledProcessError(1, ['maas', 'login'])
365+ with patch('subprocess.check_call', autospec=True,
366+ side_effect=login_error) as cc_mock:
367+ with self.assertRaises(CalledProcessError) as ctx:
368+ with maas_account_from_config(config):
369+ self.fail('Should never get manager with failed login')
370+ self.assertIs(ctx.exception, login_error)
371+ self.assertEquals(cc_mock.call_args_list, [
372+ call(['maas', 'login', 'mas',
373+ 'http://10.0.10.10/MAAS/api/2.0/',
374+ 'a:password:string']),
375+ call(['maas', 'login', 'mas',
376+ 'http://10.0.10.10/MAAS/api/1.0/',
377+ 'a:password:string']),
378+ ])
379+ self.assertEqual(
380+ self.log_stream.getvalue(),
381+ 'INFO Could not login with MAAS 2.0 API, trying 1.0\n')
382+
383+
384 class TestMakeSubstrateManager(TestCase):
385
386 def test_make_substrate_manager_aws(self):

Subscribers

People subscribed via source and target branches