Merge lp:~jason-hobbs/maas/add-umg-cli-api into lp:~maas-committers/maas/trunk

Proposed by Jason Hobbs
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
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.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good. Great test coverage. Only a couple of small things.

review: Approve
Revision history for this message
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

Revision history for this message
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.

review: Approve
Revision history for this message
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.

Revision history for this message
MAAS Lander (maas-lander) wrote :

Attempt to merge into lp:maas failed due to conflicts:

missing parent in src/provisioningserver/custom_hardware
unversioned parent in src/provisioningserver/custom_hardware
missing parent in src/provisioningserver/custom_hardware/tests
unversioned parent in src/provisioningserver/custom_hardware/tests

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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)