Merge lp:~jason-hobbs/maas/add-umg-cli-api into lp:~maas-committers/maas/trunk
- add-umg-cli-api
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Jason Hobbs |
Approved revision: | no longer in the source branch. |
Merged at revision: | 2412 |
Proposed branch: | lp:~jason-hobbs/maas/add-umg-cli-api |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
388 lines (+368/-0) 3 files modified
required-packages/base (+1/-0) src/provisioningserver/drivers/hardware/tests/test_umg.py (+218/-0) src/provisioningserver/drivers/hardware/umg.py (+149/-0) |
To merge this branch: | bzr merge lp:~jason-hobbs/maas/add-umg-cli-api |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jeroen T. Vermeulen (community) | Approve | ||
Blake Rouse (community) | Approve | ||
Review via email: mp+221588@code.launchpad.net |
Commit message
Add basic UMG CLI API support.
Description of the change
This introduces the basic API code that MAAS will use to interact with the UMG. Additional code will be needed to make this useful to MAAS.
Jason Hobbs (jason-hobbs) wrote : | # |
Blake,
Thanks for the review. I made most of the changes you suggested - a few
comments/responses inline below.
On Mon, Jun 2, 2014 at 2:52 PM, Blake Rouse <email address hidden>
wrote:
> > + power_control = choice(['off', 'on', 'cycle'])
>
> Enum for power_control, as they are specific.
>
I just made the test more generic. The server side checks for those values,
but the client doesn't so I don't want to introduce a new enum.
>
> > +
> > +
> > +str = None
> > +
> > +__metaclass__ = type
> > +__all__ = []
>
> This is normally at the beginning of file, before any imports. Is this
> because of paramiko?
>
Cool - you're right, just didn't notice that pattern. Fixed.
> + return output
>
> Do you need any form of encoding here? Or will it always be a supported
> encoding?
>
It's always been ascii so far :) It's not a generic shell so the encoding
shouldn't be changing.
> +
> > + Targets are SP names. Valid controls are 'cycle', 'on', 'off'.
>
> Should use an enum for control, as they are specific.
>
I don't want to get into enforcing stuff on the client side that the server
can and will enforce on the server side. I don't actually check for the
values in my code, so I'm not going to introduce an unused enum.
Thanks,
Jason
Jeroen T. Vermeulen (jtv) wrote : | # |
Good branch, good review, as far as I'm concerned! My only remark is about the question of compiling regular expressions: I don't mind either way, but when it comes to deciding, don't ask "which way is faster?" Ask "do I expect this to be a performance problem?" Next ask "which way is clearer?"
Knuth's Law applies. In this case, the standard library caches compiled regexes under the hood anyway, so the worst cases are already covered.
Jason Hobbs (jason-hobbs) wrote : | # |
jtv, thanks for the review! I think taking the pattern definition out of the middle of the rest of the code does make it clearer.
MAAS Lander (maas-lander) wrote : | # |
Attempt to merge into lp:maas failed due to conflicts:
missing parent in src/provisionin
unversioned parent in src/provisionin
missing parent in src/provisionin
unversioned parent in src/provisionin
Preview Diff
1 | === modified file 'required-packages/base' |
2 | --- required-packages/base 2014-04-25 13:07:56 +0000 |
3 | +++ required-packages/base 2014-06-10 14:58:15 +0000 |
4 | @@ -35,6 +35,7 @@ |
5 | python-oops-datedir-repo |
6 | python-oops-twisted |
7 | python-oops-wsgi |
8 | +python-paramiko |
9 | python-pexpect |
10 | python-psycopg2 |
11 | python-pyinotify |
12 | |
13 | === added file 'src/provisioningserver/drivers/hardware/tests/test_umg.py' |
14 | --- src/provisioningserver/drivers/hardware/tests/test_umg.py 1970-01-01 00:00:00 +0000 |
15 | +++ src/provisioningserver/drivers/hardware/tests/test_umg.py 2014-06-10 14:58:15 +0000 |
16 | @@ -0,0 +1,218 @@ |
17 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
18 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
19 | + |
20 | +"""Tests for ``provisioningserver.drivers.hardware.umg``.""" |
21 | + |
22 | +from __future__ import ( |
23 | + absolute_import, |
24 | + print_function, |
25 | + unicode_literals, |
26 | + ) |
27 | + |
28 | +str = None |
29 | + |
30 | +__metaclass__ = type |
31 | +__all__ = [] |
32 | + |
33 | +from random import ( |
34 | + randint, |
35 | + shuffle, |
36 | + ) |
37 | +from StringIO import StringIO |
38 | + |
39 | +from maastesting.factory import factory |
40 | +from maastesting.matchers import MockCalledOnceWith |
41 | +from maastesting.testcase import MAASTestCase |
42 | +from mock import Mock |
43 | +from provisioningserver.drivers.hardware.umg import ( |
44 | + ShowOutput, |
45 | + UMG_CLI_API, |
46 | + ) |
47 | + |
48 | + |
49 | +def make_umg_api(): |
50 | + """Make a UMG_CLI_API object with randomized parameters.""" |
51 | + host = factory.make_hostname('umg') |
52 | + username = factory.make_name('user') |
53 | + password = factory.make_name('password') |
54 | + return UMG_CLI_API(host, username, password) |
55 | + |
56 | + |
57 | +def make_streams(stdin=None, stdout=None, stderr=None): |
58 | + """Make a fake return value for a SSHClient.exec_command.""" |
59 | + # stdout.read() is called so stdout can't be None. |
60 | + if stdout is None: |
61 | + stdout = Mock() |
62 | + |
63 | + return (stdin, stdout, stderr) |
64 | + |
65 | + |
66 | +class TestRunCliCommand(MAASTestCase): |
67 | + """Tests for ``UMG_CLI_API.run_cli_command``. |
68 | + """ |
69 | + |
70 | + def test_uses_full_cli_command(self): |
71 | + api = make_umg_api() |
72 | + ssh_mock = self.patch(api, '_ssh') |
73 | + ssh_mock.exec_command = Mock(return_value=make_streams()) |
74 | + command = factory.make_name('command') |
75 | + api._run_cli_command(command) |
76 | + full_command = 'cli -s %s' % command |
77 | + self.assertThat( |
78 | + ssh_mock.exec_command, MockCalledOnceWith(full_command)) |
79 | + |
80 | + def test_returns_output(self): |
81 | + api = make_umg_api() |
82 | + ssh_mock = self.patch(api, '_ssh') |
83 | + expected = factory.make_name('output') |
84 | + stdout = StringIO(expected) |
85 | + streams = make_streams(stdout=stdout) |
86 | + ssh_mock.exec_command = Mock(return_value=streams) |
87 | + output = api._run_cli_command(factory.make_name('command')) |
88 | + self.assertEqual(expected, output) |
89 | + |
90 | + def test_connects_and_closes_ssh_client(self): |
91 | + api = make_umg_api() |
92 | + ssh_mock = self.patch(api, '_ssh') |
93 | + ssh_mock.exec_command = Mock(return_value=make_streams()) |
94 | + api._run_cli_command(factory.make_name('command')) |
95 | + self.assertThat( |
96 | + ssh_mock.connect, |
97 | + MockCalledOnceWith( |
98 | + api.host, username=api.username, password=api.password)) |
99 | + self.assertThat(ssh_mock.close, MockCalledOnceWith()) |
100 | + |
101 | + def test_closes_when_exception_raised(self): |
102 | + api = make_umg_api() |
103 | + ssh_mock = self.patch(api, '_ssh') |
104 | + |
105 | + def fail(): |
106 | + raise Exception('fail') |
107 | + |
108 | + ssh_mock.exec_command = Mock(side_effect=fail) |
109 | + command = factory.make_name('command') |
110 | + self.assertRaises(Exception, api._run_cli_command, command) |
111 | + self.assertThat(ssh_mock.close, MockCalledOnceWith()) |
112 | + |
113 | + |
114 | +def make_directories(count): |
115 | + """Make a fake CLI directory listing. |
116 | + |
117 | + Returns a tuple of the list of the directories, and the raw text of |
118 | + the directories (to be parsed). |
119 | + """ |
120 | + names = list(factory.make_names(*(['directory'] * count))) |
121 | + directories = ['|__%s\r\n' % name for name in names] |
122 | + return names, ''.join(directories) |
123 | + |
124 | + |
125 | +def make_settings(count): |
126 | + """Make a fake CLI settings listing. |
127 | + |
128 | + Returns a tuple of the list of the settings, and the raw text of the |
129 | + settings (to be parsed). |
130 | + """ |
131 | + keys = ['key%d' % i for i in range(count)] |
132 | + records = {key: factory.make_name('value') for key in keys} |
133 | + settings = [' * %s=%s\r\n' % (k, v) for k, v in records.iteritems()] |
134 | + return records, ''.join(settings) |
135 | + |
136 | + |
137 | +class TestParseShowOutput(MAASTestCase): |
138 | + """Tests for ``UMG_CLI_API._parse_show_output``.""" |
139 | + |
140 | + def test_only_directories(self): |
141 | + api = make_umg_api() |
142 | + directories, parser_input = make_directories(randint(1, 10)) |
143 | + results = api._parse_show_output(parser_input) |
144 | + self.assertEqual(directories, results.directories) |
145 | + |
146 | + def test_only_settings(self): |
147 | + api = make_umg_api() |
148 | + settings, parser_input = make_settings(randint(1, 10)) |
149 | + results = api._parse_show_output(parser_input) |
150 | + self.assertEqual(settings, results.settings) |
151 | + |
152 | + def test_empty_input(self): |
153 | + api = make_umg_api() |
154 | + results = api._parse_show_output('') |
155 | + self.assertEqual(ShowOutput([], {}), results) |
156 | + |
157 | + def test_directories_and_settings_in_mixed_order(self): |
158 | + api = make_umg_api() |
159 | + directories, directories_input = make_directories(randint(1, 10)) |
160 | + settings, settings_input = make_settings(randint(1, 10)) |
161 | + combined = directories_input + settings_input |
162 | + lines = combined.split('\r\n') |
163 | + shuffle(lines) |
164 | + shuffled = '\r\n'.join(lines) + '\r\n' |
165 | + results = api._parse_show_output(shuffled) |
166 | + self.assertItemsEqual(directories, results.directories) |
167 | + self.assertEqual(settings, results.settings) |
168 | + |
169 | + |
170 | +class TestShowCommand(MAASTestCase): |
171 | + """Tests for ``UMG_CLI_API._show_command``.""" |
172 | + |
173 | + def test_runs_command_and_returns_parsed_results(self): |
174 | + api = make_umg_api() |
175 | + command = factory.make_name('command') |
176 | + run_cli_command = self.patch(api, '_run_cli_command') |
177 | + run_cli_command.return_value = factory.make_name('output_text') |
178 | + parse_show_output = self.patch(api, '_parse_show_output') |
179 | + parse_show_output.return_value = factory.make_name('parsed_result') |
180 | + result = api._show_command(command) |
181 | + self.assertThat(run_cli_command, MockCalledOnceWith(command)) |
182 | + expected_call = MockCalledOnceWith(run_cli_command.return_value) |
183 | + self.assertThat(parse_show_output, expected_call) |
184 | + self.assertEqual(parse_show_output.return_value, result) |
185 | + |
186 | + |
187 | +class TestShowTargets(MAASTestCase): |
188 | + """Tests for ``UMG_CLI_API.show_targets``.""" |
189 | + |
190 | + def test_uses_correct_command(self): |
191 | + api = make_umg_api() |
192 | + show_command = self.patch(api, '_show_command') |
193 | + api.show_targets() |
194 | + self.assertThat(show_command, MockCalledOnceWith('show /targets/SP')) |
195 | + |
196 | + def test_returns_command_output(self): |
197 | + api = make_umg_api() |
198 | + show_command = self.patch(api, '_show_command') |
199 | + show_command.return_value = factory.make_name('result') |
200 | + result = api.show_targets() |
201 | + self.assertEqual(show_command.return_value, result) |
202 | + |
203 | + |
204 | +class TestShowTarget(MAASTestCase): |
205 | + """Tests for ``UMG_CLI_API.show_target``.""" |
206 | + |
207 | + def test_uses_correct_command(self): |
208 | + api = make_umg_api() |
209 | + show_command = self.patch(api, '_show_command') |
210 | + target = factory.make_name('target') |
211 | + api.show_target(target) |
212 | + expected = 'show /targets/SP/%s' % (target) |
213 | + self.assertThat(show_command, MockCalledOnceWith(expected)) |
214 | + |
215 | + def test_returns_command_output(self): |
216 | + api = make_umg_api() |
217 | + show_command = self.patch(api, '_show_command') |
218 | + show_command.return_value = factory.make_name('result') |
219 | + result = api.show_target(factory.make_name('target')) |
220 | + self.assertEqual(show_command.return_value, result) |
221 | + |
222 | + |
223 | +class TestPowerControlTarget(MAASTestCase): |
224 | + """"Tests for ``UMG_CLI_API.power_control_target``.""" |
225 | + |
226 | + def test_uses_correct_command(self): |
227 | + api = make_umg_api() |
228 | + target = factory.make_name('target') |
229 | + power_control = factory.make_name('power_control') |
230 | + run_cli_command = self.patch(api, '_run_cli_command') |
231 | + api.power_control_target(target, power_control) |
232 | + expected = 'set targets/SP/%s/powerControl powerCtrlType=%s' % ( |
233 | + target, power_control) |
234 | + self.assertThat(run_cli_command, MockCalledOnceWith(expected)) |
235 | |
236 | === added file 'src/provisioningserver/drivers/hardware/umg.py' |
237 | --- src/provisioningserver/drivers/hardware/umg.py 1970-01-01 00:00:00 +0000 |
238 | +++ src/provisioningserver/drivers/hardware/umg.py 2014-06-10 14:58:15 +0000 |
239 | @@ -0,0 +1,149 @@ |
240 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
241 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
242 | + |
243 | +"""Support for managing nodes via a Emerson Network Power Universal |
244 | +Management Gatway (UMG). |
245 | + |
246 | +The UMG is an appliance for managing servers and other devices remotely. |
247 | +It can interact with managed systems via IPMI and other network |
248 | +protocols, providing services like remote console, power control, and |
249 | +sensor aggregration. |
250 | + |
251 | +This module provides support for interacting UMG's SSH based CLI, and |
252 | +for using that support to allow MAAS to manage systems via UMG. |
253 | +""" |
254 | + |
255 | +from __future__ import ( |
256 | + absolute_import, |
257 | + print_function, |
258 | + unicode_literals, |
259 | + ) |
260 | +str = None |
261 | + |
262 | +__metaclass__ = type |
263 | +__all__ = [] |
264 | + |
265 | +from collections import namedtuple |
266 | +import re |
267 | + |
268 | +import paramiko |
269 | + |
270 | + |
271 | +ShowOutput = namedtuple('ShowOutput', ('directories', 'settings')) |
272 | + |
273 | +directories_pattern = r''' |
274 | + ^\|__ # Start of line followed by '|__' |
275 | + (.*) # Capture remaining text on line |
276 | + \r$ # End of line with carriage return. |
277 | +''' |
278 | +directories_re = re.compile(directories_pattern, re.MULTILINE | re.VERBOSE) |
279 | + |
280 | +settings_pattern = r''' |
281 | + ^\s\*\s # Start of line followed by ' * ' |
282 | + (\w+)=(.*) # Capture key and value |
283 | + \r$ # End of line with carriage return. |
284 | +''' |
285 | +settings_re = re.compile(settings_pattern, re.MULTILINE | re.VERBOSE) |
286 | + |
287 | + |
288 | +class UMG_CLI_API: |
289 | + """An API for interacting with the UMG CLI. |
290 | + |
291 | + This API exposes actions that are useful to MAAS, but not |
292 | + necessarily general purpose. |
293 | + |
294 | + Some terms: |
295 | + command - input for the CLI. The CLI will run a command and return |
296 | + some output. |
297 | + SP - Service Processor. A service processor sometimes, but |
298 | + not always, manages a server. There are also service processors for |
299 | + other things (CMM). |
300 | + target - things managed by the UMG. For MAAS's use, these are |
301 | + servers. |
302 | + """ |
303 | + |
304 | + def __init__(self, host, username, password): |
305 | + self.host = host |
306 | + self.username = username |
307 | + self.password = password |
308 | + self._ssh = paramiko.SSHClient() |
309 | + self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) |
310 | + |
311 | + def _run_cli_command(self, command): |
312 | + """Run a single command and return unparsed text from stdout. |
313 | + |
314 | + This method opens and closes an SSH connection each time it's |
315 | + called - the CLI doesn't appear to support multiple commands per |
316 | + connection. |
317 | + """ |
318 | + full_command = 'cli -s %s' % (command) |
319 | + self._ssh.connect( |
320 | + self.host, username=self.username, password=self.password) |
321 | + try: |
322 | + _, stdout, _ = self._ssh.exec_command(full_command) |
323 | + output = stdout.read() |
324 | + finally: |
325 | + self._ssh.close() |
326 | + |
327 | + return output |
328 | + |
329 | + def _parse_show_output(self, text): |
330 | + """Parse the output of a 'show' command. |
331 | + |
332 | + The show command shows information about a directory. The |
333 | + directory may contain other directories, and may have settings. |
334 | + |
335 | + This method returns a list of the directories and a dictionary |
336 | + containing the settings, encapsulated in a ShowOutput object. |
337 | + |
338 | + Each line of output is follwed by '\r\n'. |
339 | + |
340 | + Example output - all directories: |
341 | + Cli>show /targets/SP |
342 | + |__1F-C9-DF_CMM-1-1 |
343 | + |__1F-C9-DF_CMM-1-2 |
344 | + |__1F-C9-DF_MMC-1-1-28 |
345 | + |__1F-C9-DF_MMC-1-1-31 |
346 | + |__1F-C9-DF_MMC-1-2-28 |
347 | + |__1F-C9-DF_MMC-1-2-31 |
348 | + |
349 | + Example output - directories and text: |
350 | + Cli>show /targets/SP/1F-C9-DF_CMM-1-1 |
351 | + * alias=1F-C9-DF_CMM-1-1 |
352 | + * internalId=181 |
353 | + * ipAddress=10.216.160.113 |
354 | + |__powerControl |
355 | + """ |
356 | + |
357 | + directories = directories_re.findall(text) |
358 | + settings = dict(settings_re.findall(text)) |
359 | + return ShowOutput(directories, settings) |
360 | + |
361 | + def _show_command(self, command): |
362 | + """Run a show command and return its parsed output.""" |
363 | + output_text = self._run_cli_command(command) |
364 | + output = self._parse_show_output(output_text) |
365 | + return output |
366 | + |
367 | + def show_targets(self): |
368 | + """Return a list of targets from /targets/SP.""" |
369 | + output = self._show_command('show /targets/SP') |
370 | + return output |
371 | + |
372 | + def show_target(self, target): |
373 | + """Show the details of a target from /target/SP. |
374 | + |
375 | + This provides informations about the SP - like IP |
376 | + address, type, and power status. |
377 | + """ |
378 | + output = self._show_command('show /targets/SP/%s' % (target)) |
379 | + return output |
380 | + |
381 | + def power_control_target(self, target, control): |
382 | + """Issue a power command to a target. |
383 | + |
384 | + Targets are SP names. Valid controls are 'cycle', 'on', 'off'. |
385 | + """ |
386 | + command = 'set targets/SP/%s/powerControl powerCtrlType=%s' % ( |
387 | + target, control) |
388 | + self._run_cli_command(command) |
Looks good. Great test coverage. Only a couple of small things.