Merge lp:~jason-hobbs/maas/lp-1210393 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: 1991
Proposed branch: lp:~jason-hobbs/maas/lp-1210393
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 456 lines (+329/-44)
2 files modified
etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py (+91/-26)
etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py (+238/-18)
To merge this branch: bzr merge lp:~jason-hobbs/maas/lp-1210393
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+207288@code.launchpad.net

Commit message

Refactor user selection.

Don't assume a fixed number of users, or rely on any given user existing. Instead, ask the BMC what users exist, and then inspect users from that list. This eliminates the dependency on a 'User10' existing.

Pick the first user with username 'maas'. If no user with username 'maas' exists, pick the first user that has no username set. Otherwise, fail IPMI setup.

Description of the change

Refactor user selection.

Don't assume a fixed number of users, or rely on any given user existing. Instead, ask the BMC what users exist, and then inspect users from that list. This eliminates the dependency on a 'User10' existing.

Pick the first user with username 'maas'. If no user with username 'maas' exists, pick the first user has no username set. Otherwise, fail IPMI setup.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (3.1 KiB)

Looks good, thanks again!

[1]

+    def test_runs_bmc_config(self):
+        """Ensure bmc-config is called with the correct args."""
+        recorder = self.patch(maas_ipmi_autodetect, 'run_command')
+
+        section = 'foo'
+        bmc_get_section(section)
+        args = recorder.call_args[0][0]
+
+        self.assertEqual('bmc-config', args[0])
+
+        for arg in ['--checkout', '--section', section]:
+            self.assertIn(arg, args)

The ordering is static so you can save some pain by using:

        recorder.assert_called_once_with(
            'bmc-config', '--checkout', '--section', section)

It'll also give a more useful error message if it fails.

I think there's also an assert_called_with() that matches any of
multiple calls, but if you need it you ought to check.

[2]

+        bgs_mock = Mock(return_value=user_text)
+        self.patch(maas_ipmi_autodetect, 'bmc_get_section', bgs_mock)
+        ps_mock = Mock(return_value=(user_number, user_record))
+        self.patch(maas_ipmi_autodetect, 'parse_section', ps_mock)

self.patch() does some of the above for you. You could also write it as:

        bgs_mock = self.patch(maas_ipmi_autodetect, 'bmc_get_section')
        bgs_mock.return_value = user_text
        ps_mock = self.patch(maas_ipmi_autodetect, 'parse_section')
        ps_mock.return_value = (user_number, user_record)

It only saves the import of Mock, but you might like it.

[3]

+        self.assertEqual(user_number, bgs_mock.call_args[0][0])
+        self.assertEqual(user_text, ps_mock.call_args[0][0])

Again, assert_called_once_with() is your friend.

If you need to match something with multiple arguments, but you can only
about one or two, there's ANY:

        from mock import ANY
        thing.assert_called_once_with("important", ANY, keyword=ANY)

There's also call(), which also works with ANY:

        from mock import call
        self.assertEqual(
            [call(1, ANY), call(2, ANY)],
            thing.call_args_list)

[4]

+def disabled_user(update=None):
+    """Record for a disabled user."""
+    base = {'Enable_User': 'No'}
+
+    if update:
+        base.update(update)
+
+    return base

Can you name this make_disabled_user()? All test factories have the
prefix "make", except where they don't, er, they have the prefix "get".

[5]

+def base_attributes(attributes_update=None):
+    """Base user records with updates in an OrderedDict."""
+
+    attributes_template = {
+        """Starting point for user records."""
+        'User1': {
+            'Enable_User': 'Yes'
+        },
+        'User2': {
+            'Username': 'admin',
+            'Enable_User': 'Yes'
+        }
+    }
+
+    base = OrderedDict(deepcopy(attributes_template))

I don't think the deepcopy() is necessary.

[6]

+    if attributes_update:
+        base.update(attributes_update)

Another habit we have is never relying on the implicit boolean-ness of
things in Python. Explicitly test for None instead:

    if attributes_update is not None:
        base.update(attributes_update)

[7]

+        ('one_disabled_not_blank_user', dict(

Fwiw, scenario names don't have to be valid identifiers; they're
free-form text I ...

Read more...

review: Approve
Revision history for this message
Jason Hobbs (jason-hobbs) wrote :

Gavin,

On 02/19/2014 04:53 PM, Gavin Panella wrote:
> Review: Approve
>
> Looks good, thanks again!

Thanks again for the review all the pointers! I've made all the changes
you suggested and updated the branch.

Regards,
Jason

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Wednesday 19 Feb 2014 22:53:25 you wrote:
> Can you name this make_disabled_user()? All test factories have the
> prefix "make", except where they don't, er, they have the prefix "get".

This has been royally p!ssing me off lately... I might just pull the trigger
and change everything to make_<thing>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py'
2--- etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py 2014-02-18 19:26:26 +0000
3+++ etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py 2014-02-20 17:53:42 +0000
4@@ -38,6 +38,10 @@
5 import time
6
7
8+class IPMIUserError(Exception):
9+ """An error related to an IPMI user."""
10+
11+
12 def run_command(command_args):
13 """Run a command. Return output if successful or raise exception if not."""
14 output = subprocess.check_output(command_args, stderr=subprocess.STDOUT)
15@@ -62,18 +66,98 @@
16 return '%s:%s' % (user_number, parameter)
17
18
19-def bmc_user_get(user_number, parameter):
20- """Get a user parameter via bmc-config commit."""
21- key = format_user_key(user_number, parameter)
22- return bmc_get(key)
23-
24-
25 def bmc_user_set(user_number, parameter, value):
26 """Set a user parameter via bmc-config commit."""
27 key = format_user_key(user_number, parameter)
28 bmc_set(key, value)
29
30
31+def parse_section(section):
32+ """Parse the text of a section.
33+
34+ Returns a tuple of ('SectionName', section_attributes_dict)
35+ """
36+ # match lines with a word followed by space and then a non commment string
37+ pattern = r'^\s*(\w+)\s+([^# \t\r\n\v\f]+).*$'
38+ kv_tuples = re.findall(pattern, section, re.MULTILINE)
39+ kv_dict = dict(kv_tuples)
40+ section_name = kv_dict.pop('Section')
41+ section_record = (section_name, kv_dict)
42+
43+ return section_record
44+
45+
46+def bmc_get_section(section_name):
47+ """Retrieve the text of a section from the BMC."""
48+ command = ('bmc-config', '--checkout', '--section', section_name)
49+ output = run_command(command)
50+ return output
51+
52+
53+def get_user_record(user_number):
54+ """Return a dictionary of the user settings for a user number."""
55+ section = bmc_get_section(user_number)
56+ _, record = parse_section(section)
57+ return record
58+
59+
60+def bmc_list_sections():
61+ """Retrieve the names of config sections from the BMC."""
62+ command = ('bmc-config', '-L')
63+ output = run_command(command)
64+ return output
65+
66+
67+def list_user_numbers():
68+ """List the user numbers on the BMC."""
69+ output = bmc_list_sections()
70+ pattern = r'^(User\d+)$'
71+ users = re.findall(pattern, output, re.MULTILINE)
72+
73+ return users
74+
75+
76+def pick_user_number_from_list(search_username, user_numbers):
77+ """Pick the best user number for a user from a list of user numbers.
78+
79+ If any any existing user's username matches the search username, pick
80+ that user.
81+
82+ Otherwise, pick the first user that has no username set.
83+
84+ If no users match those criteria, raise an IPMIUserError.
85+ """
86+ first_unused = None
87+
88+ for user_number in user_numbers:
89+ # The IPMI spec reserves User1 as anonymous.
90+ if user_number == 'User1':
91+ continue
92+
93+ user_record = get_user_record(user_number)
94+
95+ username = user_record.get('Username')
96+
97+ if username == search_username:
98+ return user_number
99+
100+ if username is None and first_unused is None:
101+ first_unused = user_number
102+
103+ return first_unused
104+
105+
106+def pick_user_number(search_username):
107+ """Pick the best user number for a username."""
108+ user_numbers = list_user_numbers()
109+ user_number = pick_user_number_from_list(search_username, user_numbers)
110+
111+ if not user_number:
112+ raise IPMIUserError('No IPMI user slots available.')
113+
114+ return user_number
115+
116+
117 def is_ipmi_dhcp():
118 output = bmc_get('Lan_Conf:IP_Address_Source')
119 show_re = re.compile('IP_Address_Source\s+Use_DHCP')
120@@ -91,26 +175,8 @@
121 return res.group()
122
123
124-def get_ipmi_user_number(user):
125- for i in range(1, 17):
126- ipmi_user_number = "User%s" % i
127- # bmc-config fails if no slot for the requested user exists;
128- # instead of bailing, just keep trying remaining users.
129- try:
130- output = bmc_user_get(ipmi_user_number, 'Username')
131- except subprocess.CalledProcessError:
132- pass
133- else:
134- if user in output:
135- return ipmi_user_number
136- return None
137-
138-
139 def commit_ipmi_user_settings(user, password):
140- ipmi_user_number = get_ipmi_user_number(user)
141- if ipmi_user_number is None:
142- bmc_user_set('User10', 'Username', user)
143- ipmi_user_number = get_ipmi_user_number(user)
144+ ipmi_user_number = pick_user_number(user)
145 bmc_user_set(ipmi_user_number, 'Username', user)
146 bmc_user_set(ipmi_user_number, 'Password', password)
147 bmc_user_set(ipmi_user_number, 'Enable_User', 'Yes')
148@@ -183,7 +249,6 @@
149 set_ipmi_network_source("Use_DHCP")
150 # allow IPMI 120 seconds to obtain an IP address
151 time.sleep(120)
152-
153 # create user/pass
154 IPMI_MAAS_USER = "maas"
155 IPMI_MAAS_PASSWORD = generate_random_password()
156
157=== modified file 'etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py'
158--- etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py 2014-02-18 19:26:26 +0000
159+++ etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py 2014-02-20 17:53:42 +0000
160@@ -16,14 +16,24 @@
161
162 import subprocess
163
164+from collections import OrderedDict
165+
166 from maastesting.testcase import MAASTestCase
167 from maastesting.factory import factory
168
169-import snippets.maas_ipmi_autodetect
170+from snippets import maas_ipmi_autodetect
171
172 from snippets.maas_ipmi_autodetect import (
173+ bmc_get_section,
174+ bmc_list_sections,
175 format_user_key,
176+ get_user_record,
177+ list_user_numbers,
178+ parse_section,
179+ pick_user_number,
180+ pick_user_number_from_list,
181 run_command,
182+ IPMIUserError
183 )
184
185
186@@ -63,8 +73,8 @@
187 self.assertEqual(expected, user_key)
188
189
190-class TestBMCMethods(MAASTestCase):
191- """Tests for the bmc_* methods."""
192+class TestBMCKeyPairMethods(MAASTestCase):
193+ """Tests for methods that use bmc-config --key-pair"""
194
195 scenarios = [
196 ('bmc_get', dict(
197@@ -73,9 +83,6 @@
198 ('bmc_set', dict(
199 method_name='bmc_set', args=['Test:Key', 'myval'],
200 key_pair_fmt='--key-pair=%s=%s', direction='--commit')),
201- ('bmc_user_get', dict(
202- method_name='bmc_user_get', args=['User10', 'Username'],
203- key_pair_fmt='--key-pair=%s:%s', direction='--checkout')),
204 ('bmc_user_set', dict(
205 method_name='bmc_user_set', args=['User10', 'Username', 'maas'],
206 key_pair_fmt='--key-pair=%s:%s=%s', direction='--commit'))
207@@ -84,24 +91,237 @@
208 def test_runs_bmc_config(self):
209 """Ensure bmc-config is run properly."""
210
211- recorder = self.patch(snippets.maas_ipmi_autodetect, 'run_command')
212+ recorder = self.patch(maas_ipmi_autodetect, 'run_command')
213
214 # Grab the method from the class module where it lives.
215- method = getattr(snippets.maas_ipmi_autodetect, self.method_name)
216+ method = getattr(maas_ipmi_autodetect, self.method_name)
217
218 method(*self.args)
219
220- # call_args[0] is a tuple of ordered args to run_command.
221- tokens = recorder.call_args[0][0]
222-
223- # Ensure bmc-config is being called.
224- self.assertEqual(tokens[0], 'bmc-config')
225-
226- # Ensure the correct --commit or --checkout is used.
227- self.assertIn(self.direction, tokens)
228-
229 # Note that the fmt string must use positional argument specifiers
230 # if the order of appearance of args in the fmt string doesn't match
231 # the order of args to the method.
232 key_pair_string = self.key_pair_fmt % tuple(self.args)
233- self.assertIn(key_pair_string, tokens)
234+
235+ expected_args = ('bmc-config', self.direction, key_pair_string)
236+ recorder.assert_called_once_with(expected_args)
237+
238+
239+class TestBMCGetSection(MAASTestCase):
240+ """Tests for bmc_get_section()."""
241+
242+ def test_runs_bmc_config(self):
243+ """Ensure bmc-config is called with the correct args."""
244+ recorder = self.patch(maas_ipmi_autodetect, 'run_command')
245+
246+ section = 'foo'
247+ bmc_get_section(section)
248+
249+ recorder.assert_called_once_with(
250+ ('bmc-config', '--checkout', '--section', section))
251+
252+
253+class TestGetUserRecord(MAASTestCase):
254+ """Tests for get_user_record()."""
255+
256+ def test_get_user_record(self):
257+ """Ensure get_user_record() processes requests properly."""
258+ user_number = 'User1'
259+ user_text = 'some text'
260+ user_record = {'bar': 'baz'}
261+
262+ bgs_mock = self.patch(maas_ipmi_autodetect, 'bmc_get_section')
263+ bgs_mock.return_value = user_text
264+ ps_mock = self.patch(maas_ipmi_autodetect, 'parse_section')
265+ ps_mock.return_value = (user_number, user_record)
266+
267+ record = get_user_record(user_number)
268+ self.assertEqual(record, user_record)
269+ bgs_mock.assert_called_once_with(user_number)
270+ ps_mock.assert_called_once_with(user_text)
271+
272+
273+class TestBMCListSections(MAASTestCase):
274+ """Tests for bmc_list_sections()."""
275+
276+ def test_bmc_list_sections(self):
277+ """Ensure bmc-config is called with the correct args."""
278+ recorder = self.patch(maas_ipmi_autodetect, 'run_command')
279+ bmc_list_sections()
280+ recorder.assert_called_once_with(('bmc-config', '-L'))
281+
282+
283+class TestListUserNumbers(MAASTestCase):
284+ """Tests for list_user_numbers()."""
285+
286+ section_names = (
287+ "User1\n"
288+ "User4\n"
289+ "User3\n"
290+ "User16\n"
291+ "User7more\n"
292+ "Userworld\n"
293+ "Otherwise\n"
294+ "4User5\n"
295+ "4User\n"
296+ "User\n"
297+ "3"
298+ )
299+
300+ def test_matching(self):
301+ """Ensure only properly formatted User sections match."""
302+ mock = self.patch(maas_ipmi_autodetect, 'bmc_list_sections')
303+ mock.return_value = self.section_names
304+ expected = ['User1', 'User4', 'User3', 'User16']
305+ user_numbers = list_user_numbers()
306+ self.assertEqual(expected, user_numbers)
307+
308+ def test_empty(self):
309+ """Ensure an empty list is handled correctly."""
310+ mock = self.patch(maas_ipmi_autodetect, 'bmc_list_sections')
311+ mock.return_value = ''
312+ results = list_user_numbers()
313+ self.assertEqual([], results)
314+
315+
316+class TestParseSection(MAASTestCase):
317+ """Tests for parse_section()."""
318+
319+ section_template = (
320+ "Section Test\n" # Section line, no leading space.
321+ "\tUsername\t\tBob\n" # Normal line.
322+ " Enabled_User\t\tNo\n" # Leading space, not tab.
323+ "\t\tPassword\t\tBobPass\n" # Multiple leading tab.
324+ "\tAnother Value\n" # Separating space, not tab.
325+ "\tCharacters\t,./;:'\"[]{}|\\`~!@$%^&*()-_+=" # Gunk.
326+ "\n" # Blank line.
327+ "\tNotMe\n" # Single word.
328+ "\t#Or Me\n" # Comment line.
329+ "\tMe #Neither\n" # Word followed by comment.
330+ "\tThree\tWord\tLine\n" # More than two words.
331+ "\tFinal\tLine" # No trailing whitespace.
332+ )
333+
334+ def test_matching(self):
335+ """Ensure only properly formatted User sections match."""
336+
337+ record = parse_section(self.section_template)
338+
339+ expected_attributes = {
340+ 'Username': 'Bob',
341+ 'Enabled_User': 'No',
342+ 'Password': 'BobPass',
343+ 'Another': 'Value',
344+ 'Characters': ",./;:'\"[]{}|\\`~!@$%^&*()-_+=",
345+ 'Three': 'Word',
346+ 'Final': 'Line'
347+ }
348+
349+ self.assertEqual('Test', record[0])
350+ self.assertEqual(expected_attributes, record[1])
351+
352+ def test_fail_missing_section_line(self):
353+ """Ensure an exception is raised if the section header is missing."""
354+ no_section = self.section_template.replace('Section Test', '')
355+ self.assertRaises(Exception, parse_section, no_section)
356+
357+
358+def make_user(update=None):
359+ """Make a simple user record."""
360+
361+ base = {'Lan_Enable_IPMI_Msgs': 'No'}
362+
363+ if update:
364+ base.update(update)
365+
366+ return base
367+
368+
369+def make_attributes(attributes_update=None):
370+ """Base user records with updates in an OrderedDict."""
371+
372+ attributes_template = {
373+ 'User1': {
374+ 'Enable_User': 'Yes'
375+ },
376+ 'User2': {
377+ 'Username': 'admin',
378+ 'Enable_User': 'Yes'
379+ }
380+ }
381+
382+ base = OrderedDict(attributes_template)
383+
384+ if attributes_update is not None:
385+ base.update(attributes_update)
386+
387+ return base
388+
389+
390+class TestPickUserNumberFromList(MAASTestCase):
391+ """Tests for pick_user_number_from_list()."""
392+
393+ scenarios = [
394+ ('Empty user list', dict(
395+ user_attributes={},
396+ expected=None)),
397+ ('Existing MAAS user', dict(
398+ user_attributes=make_attributes({
399+ 'User4': make_user(),
400+ 'User5': {'Username': 'maas'}}),
401+ expected='User5')),
402+ ('One blank user', dict(
403+ user_attributes=make_attributes(
404+ {'User7': make_user()}),
405+ expected='User7')),
406+ ('Multiple blank users', dict(
407+ user_attributes=make_attributes({
408+ 'User7': make_user(),
409+ 'User8': make_user()}),
410+ expected='User7')),
411+ ('One not blank user', dict(
412+ user_attributes=make_attributes(
413+ {'User7': make_user({'Username': 'foo'})}),
414+ expected=None)),
415+ ('One enabled blank user', dict(
416+ user_attributes=make_attributes({
417+ 'User7': {'Enable_User': 'Yes'}}),
418+ expected='User7')),
419+ ('Skip User1', dict(
420+ user_attributes=make_attributes(
421+ {'User1': make_user()}),
422+ expected=None))
423+ ]
424+
425+ def fetch_user_record(self, user_number):
426+ """Return the mock user data for a user_number."""
427+ return self.user_attributes[user_number]
428+
429+ def test_user_choice(self):
430+ """Ensure the correct user, if any, is chosen."""
431+
432+ mock = self.patch(maas_ipmi_autodetect, 'get_user_record')
433+ mock.side_effect = self.fetch_user_record
434+ current_users = self.user_attributes.keys()
435+ user = pick_user_number_from_list('maas', current_users)
436+ self.assertEqual(self.expected, user)
437+
438+
439+class TestPickUserNumber(MAASTestCase):
440+ """Tests for pick_user_number()."""
441+
442+ def test_pick_user_number(self):
443+ """Ensure proper listing and selection of a user."""
444+ lun_mock = self.patch(maas_ipmi_autodetect, 'list_user_numbers')
445+ lun_mock.return_value = ['User1', 'User2']
446+ punfl_mock = self.patch(maas_ipmi_autodetect,
447+ 'pick_user_number_from_list')
448+ punfl_mock.return_value = 'User2'
449+ user_number = pick_user_number('maas')
450+ self.assertEqual('User2', user_number)
451+
452+ def test_fail_raise_exception(self):
453+ """Ensure an exception is raised if no acceptable user is found."""
454+ mock = self.patch(maas_ipmi_autodetect, 'list_user_numbers')
455+ mock.return_value = []
456+ self.assertRaises(IPMIUserError, pick_user_number, 'maas')