Merge lp:~gz/juju-ci-tools/substrate_maas2 into lp:juju-ci-tools
- substrate_maas2
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Curtis Hovey (community) | code | Approve | |
Review via email: mp+293175@code.launchpad.net |
Commit message
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.
- 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): |
Thank you.