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
=== modified file 'etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py'
--- etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py 2014-02-18 19:26:26 +0000
+++ etc/maas/templates/commissioning-user-data/snippets/maas_ipmi_autodetect.py 2014-02-20 17:53:42 +0000
@@ -38,6 +38,10 @@
38import time38import time
3939
4040
41class IPMIUserError(Exception):
42 """An error related to an IPMI user."""
43
44
41def run_command(command_args):45def run_command(command_args):
42 """Run a command. Return output if successful or raise exception if not."""46 """Run a command. Return output if successful or raise exception if not."""
43 output = subprocess.check_output(command_args, stderr=subprocess.STDOUT)47 output = subprocess.check_output(command_args, stderr=subprocess.STDOUT)
@@ -62,18 +66,98 @@
62 return '%s:%s' % (user_number, parameter)66 return '%s:%s' % (user_number, parameter)
6367
6468
65def bmc_user_get(user_number, parameter):
66 """Get a user parameter via bmc-config commit."""
67 key = format_user_key(user_number, parameter)
68 return bmc_get(key)
69
70
71def bmc_user_set(user_number, parameter, value):69def bmc_user_set(user_number, parameter, value):
72 """Set a user parameter via bmc-config commit."""70 """Set a user parameter via bmc-config commit."""
73 key = format_user_key(user_number, parameter)71 key = format_user_key(user_number, parameter)
74 bmc_set(key, value)72 bmc_set(key, value)
7573
7674
75def parse_section(section):
76 """Parse the text of a section.
77
78 Returns a tuple of ('SectionName', section_attributes_dict)
79 """
80 # match lines with a word followed by space and then a non commment string
81 pattern = r'^\s*(\w+)\s+([^# \t\r\n\v\f]+).*$'
82 kv_tuples = re.findall(pattern, section, re.MULTILINE)
83 kv_dict = dict(kv_tuples)
84 section_name = kv_dict.pop('Section')
85 section_record = (section_name, kv_dict)
86
87 return section_record
88
89
90def bmc_get_section(section_name):
91 """Retrieve the text of a section from the BMC."""
92 command = ('bmc-config', '--checkout', '--section', section_name)
93 output = run_command(command)
94 return output
95
96
97def get_user_record(user_number):
98 """Return a dictionary of the user settings for a user number."""
99 section = bmc_get_section(user_number)
100 _, record = parse_section(section)
101 return record
102
103
104def bmc_list_sections():
105 """Retrieve the names of config sections from the BMC."""
106 command = ('bmc-config', '-L')
107 output = run_command(command)
108 return output
109
110
111def list_user_numbers():
112 """List the user numbers on the BMC."""
113 output = bmc_list_sections()
114 pattern = r'^(User\d+)$'
115 users = re.findall(pattern, output, re.MULTILINE)
116
117 return users
118
119
120def pick_user_number_from_list(search_username, user_numbers):
121 """Pick the best user number for a user from a list of user numbers.
122
123 If any any existing user's username matches the search username, pick
124 that user.
125
126 Otherwise, pick the first user that has no username set.
127
128 If no users match those criteria, raise an IPMIUserError.
129 """
130 first_unused = None
131
132 for user_number in user_numbers:
133 # The IPMI spec reserves User1 as anonymous.
134 if user_number == 'User1':
135 continue
136
137 user_record = get_user_record(user_number)
138
139 username = user_record.get('Username')
140
141 if username == search_username:
142 return user_number
143
144 if username is None and first_unused is None:
145 first_unused = user_number
146
147 return first_unused
148
149
150def pick_user_number(search_username):
151 """Pick the best user number for a username."""
152 user_numbers = list_user_numbers()
153 user_number = pick_user_number_from_list(search_username, user_numbers)
154
155 if not user_number:
156 raise IPMIUserError('No IPMI user slots available.')
157
158 return user_number
159
160
77def is_ipmi_dhcp():161def is_ipmi_dhcp():
78 output = bmc_get('Lan_Conf:IP_Address_Source')162 output = bmc_get('Lan_Conf:IP_Address_Source')
79 show_re = re.compile('IP_Address_Source\s+Use_DHCP')163 show_re = re.compile('IP_Address_Source\s+Use_DHCP')
@@ -91,26 +175,8 @@
91 return res.group()175 return res.group()
92176
93177
94def get_ipmi_user_number(user):
95 for i in range(1, 17):
96 ipmi_user_number = "User%s" % i
97 # bmc-config fails if no slot for the requested user exists;
98 # instead of bailing, just keep trying remaining users.
99 try:
100 output = bmc_user_get(ipmi_user_number, 'Username')
101 except subprocess.CalledProcessError:
102 pass
103 else:
104 if user in output:
105 return ipmi_user_number
106 return None
107
108
109def commit_ipmi_user_settings(user, password):178def commit_ipmi_user_settings(user, password):
110 ipmi_user_number = get_ipmi_user_number(user)179 ipmi_user_number = pick_user_number(user)
111 if ipmi_user_number is None:
112 bmc_user_set('User10', 'Username', user)
113 ipmi_user_number = get_ipmi_user_number(user)
114 bmc_user_set(ipmi_user_number, 'Username', user)180 bmc_user_set(ipmi_user_number, 'Username', user)
115 bmc_user_set(ipmi_user_number, 'Password', password)181 bmc_user_set(ipmi_user_number, 'Password', password)
116 bmc_user_set(ipmi_user_number, 'Enable_User', 'Yes')182 bmc_user_set(ipmi_user_number, 'Enable_User', 'Yes')
@@ -183,7 +249,6 @@
183 set_ipmi_network_source("Use_DHCP")249 set_ipmi_network_source("Use_DHCP")
184 # allow IPMI 120 seconds to obtain an IP address250 # allow IPMI 120 seconds to obtain an IP address
185 time.sleep(120)251 time.sleep(120)
186
187 # create user/pass252 # create user/pass
188 IPMI_MAAS_USER = "maas"253 IPMI_MAAS_USER = "maas"
189 IPMI_MAAS_PASSWORD = generate_random_password()254 IPMI_MAAS_PASSWORD = generate_random_password()
190255
=== modified file 'etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py'
--- etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py 2014-02-18 19:26:26 +0000
+++ etc/maas/templates/commissioning-user-data/snippets/tests/test_maas_ipmi_autodetect.py 2014-02-20 17:53:42 +0000
@@ -16,14 +16,24 @@
1616
17import subprocess17import subprocess
1818
19from collections import OrderedDict
20
19from maastesting.testcase import MAASTestCase21from maastesting.testcase import MAASTestCase
20from maastesting.factory import factory22from maastesting.factory import factory
2123
22import snippets.maas_ipmi_autodetect24from snippets import maas_ipmi_autodetect
2325
24from snippets.maas_ipmi_autodetect import (26from snippets.maas_ipmi_autodetect import (
27 bmc_get_section,
28 bmc_list_sections,
25 format_user_key,29 format_user_key,
30 get_user_record,
31 list_user_numbers,
32 parse_section,
33 pick_user_number,
34 pick_user_number_from_list,
26 run_command,35 run_command,
36 IPMIUserError
27 )37 )
2838
2939
@@ -63,8 +73,8 @@
63 self.assertEqual(expected, user_key)73 self.assertEqual(expected, user_key)
6474
6575
66class TestBMCMethods(MAASTestCase):76class TestBMCKeyPairMethods(MAASTestCase):
67 """Tests for the bmc_* methods."""77 """Tests for methods that use bmc-config --key-pair"""
6878
69 scenarios = [79 scenarios = [
70 ('bmc_get', dict(80 ('bmc_get', dict(
@@ -73,9 +83,6 @@
73 ('bmc_set', dict(83 ('bmc_set', dict(
74 method_name='bmc_set', args=['Test:Key', 'myval'],84 method_name='bmc_set', args=['Test:Key', 'myval'],
75 key_pair_fmt='--key-pair=%s=%s', direction='--commit')),85 key_pair_fmt='--key-pair=%s=%s', direction='--commit')),
76 ('bmc_user_get', dict(
77 method_name='bmc_user_get', args=['User10', 'Username'],
78 key_pair_fmt='--key-pair=%s:%s', direction='--checkout')),
79 ('bmc_user_set', dict(86 ('bmc_user_set', dict(
80 method_name='bmc_user_set', args=['User10', 'Username', 'maas'],87 method_name='bmc_user_set', args=['User10', 'Username', 'maas'],
81 key_pair_fmt='--key-pair=%s:%s=%s', direction='--commit'))88 key_pair_fmt='--key-pair=%s:%s=%s', direction='--commit'))
@@ -84,24 +91,237 @@
84 def test_runs_bmc_config(self):91 def test_runs_bmc_config(self):
85 """Ensure bmc-config is run properly."""92 """Ensure bmc-config is run properly."""
8693
87 recorder = self.patch(snippets.maas_ipmi_autodetect, 'run_command')94 recorder = self.patch(maas_ipmi_autodetect, 'run_command')
8895
89 # Grab the method from the class module where it lives.96 # Grab the method from the class module where it lives.
90 method = getattr(snippets.maas_ipmi_autodetect, self.method_name)97 method = getattr(maas_ipmi_autodetect, self.method_name)
9198
92 method(*self.args)99 method(*self.args)
93100
94 # call_args[0] is a tuple of ordered args to run_command.
95 tokens = recorder.call_args[0][0]
96
97 # Ensure bmc-config is being called.
98 self.assertEqual(tokens[0], 'bmc-config')
99
100 # Ensure the correct --commit or --checkout is used.
101 self.assertIn(self.direction, tokens)
102
103 # Note that the fmt string must use positional argument specifiers101 # Note that the fmt string must use positional argument specifiers
104 # if the order of appearance of args in the fmt string doesn't match102 # if the order of appearance of args in the fmt string doesn't match
105 # the order of args to the method.103 # the order of args to the method.
106 key_pair_string = self.key_pair_fmt % tuple(self.args)104 key_pair_string = self.key_pair_fmt % tuple(self.args)
107 self.assertIn(key_pair_string, tokens)105
106 expected_args = ('bmc-config', self.direction, key_pair_string)
107 recorder.assert_called_once_with(expected_args)
108
109
110class TestBMCGetSection(MAASTestCase):
111 """Tests for bmc_get_section()."""
112
113 def test_runs_bmc_config(self):
114 """Ensure bmc-config is called with the correct args."""
115 recorder = self.patch(maas_ipmi_autodetect, 'run_command')
116
117 section = 'foo'
118 bmc_get_section(section)
119
120 recorder.assert_called_once_with(
121 ('bmc-config', '--checkout', '--section', section))
122
123
124class TestGetUserRecord(MAASTestCase):
125 """Tests for get_user_record()."""
126
127 def test_get_user_record(self):
128 """Ensure get_user_record() processes requests properly."""
129 user_number = 'User1'
130 user_text = 'some text'
131 user_record = {'bar': 'baz'}
132
133 bgs_mock = self.patch(maas_ipmi_autodetect, 'bmc_get_section')
134 bgs_mock.return_value = user_text
135 ps_mock = self.patch(maas_ipmi_autodetect, 'parse_section')
136 ps_mock.return_value = (user_number, user_record)
137
138 record = get_user_record(user_number)
139 self.assertEqual(record, user_record)
140 bgs_mock.assert_called_once_with(user_number)
141 ps_mock.assert_called_once_with(user_text)
142
143
144class TestBMCListSections(MAASTestCase):
145 """Tests for bmc_list_sections()."""
146
147 def test_bmc_list_sections(self):
148 """Ensure bmc-config is called with the correct args."""
149 recorder = self.patch(maas_ipmi_autodetect, 'run_command')
150 bmc_list_sections()
151 recorder.assert_called_once_with(('bmc-config', '-L'))
152
153
154class TestListUserNumbers(MAASTestCase):
155 """Tests for list_user_numbers()."""
156
157 section_names = (
158 "User1\n"
159 "User4\n"
160 "User3\n"
161 "User16\n"
162 "User7more\n"
163 "Userworld\n"
164 "Otherwise\n"
165 "4User5\n"
166 "4User\n"
167 "User\n"
168 "3"
169 )
170
171 def test_matching(self):
172 """Ensure only properly formatted User sections match."""
173 mock = self.patch(maas_ipmi_autodetect, 'bmc_list_sections')
174 mock.return_value = self.section_names
175 expected = ['User1', 'User4', 'User3', 'User16']
176 user_numbers = list_user_numbers()
177 self.assertEqual(expected, user_numbers)
178
179 def test_empty(self):
180 """Ensure an empty list is handled correctly."""
181 mock = self.patch(maas_ipmi_autodetect, 'bmc_list_sections')
182 mock.return_value = ''
183 results = list_user_numbers()
184 self.assertEqual([], results)
185
186
187class TestParseSection(MAASTestCase):
188 """Tests for parse_section()."""
189
190 section_template = (
191 "Section Test\n" # Section line, no leading space.
192 "\tUsername\t\tBob\n" # Normal line.
193 " Enabled_User\t\tNo\n" # Leading space, not tab.
194 "\t\tPassword\t\tBobPass\n" # Multiple leading tab.
195 "\tAnother Value\n" # Separating space, not tab.
196 "\tCharacters\t,./;:'\"[]{}|\\`~!@$%^&*()-_+=" # Gunk.
197 "\n" # Blank line.
198 "\tNotMe\n" # Single word.
199 "\t#Or Me\n" # Comment line.
200 "\tMe #Neither\n" # Word followed by comment.
201 "\tThree\tWord\tLine\n" # More than two words.
202 "\tFinal\tLine" # No trailing whitespace.
203 )
204
205 def test_matching(self):
206 """Ensure only properly formatted User sections match."""
207
208 record = parse_section(self.section_template)
209
210 expected_attributes = {
211 'Username': 'Bob',
212 'Enabled_User': 'No',
213 'Password': 'BobPass',
214 'Another': 'Value',
215 'Characters': ",./;:'\"[]{}|\\`~!@$%^&*()-_+=",
216 'Three': 'Word',
217 'Final': 'Line'
218 }
219
220 self.assertEqual('Test', record[0])
221 self.assertEqual(expected_attributes, record[1])
222
223 def test_fail_missing_section_line(self):
224 """Ensure an exception is raised if the section header is missing."""
225 no_section = self.section_template.replace('Section Test', '')
226 self.assertRaises(Exception, parse_section, no_section)
227
228
229def make_user(update=None):
230 """Make a simple user record."""
231
232 base = {'Lan_Enable_IPMI_Msgs': 'No'}
233
234 if update:
235 base.update(update)
236
237 return base
238
239
240def make_attributes(attributes_update=None):
241 """Base user records with updates in an OrderedDict."""
242
243 attributes_template = {
244 'User1': {
245 'Enable_User': 'Yes'
246 },
247 'User2': {
248 'Username': 'admin',
249 'Enable_User': 'Yes'
250 }
251 }
252
253 base = OrderedDict(attributes_template)
254
255 if attributes_update is not None:
256 base.update(attributes_update)
257
258 return base
259
260
261class TestPickUserNumberFromList(MAASTestCase):
262 """Tests for pick_user_number_from_list()."""
263
264 scenarios = [
265 ('Empty user list', dict(
266 user_attributes={},
267 expected=None)),
268 ('Existing MAAS user', dict(
269 user_attributes=make_attributes({
270 'User4': make_user(),
271 'User5': {'Username': 'maas'}}),
272 expected='User5')),
273 ('One blank user', dict(
274 user_attributes=make_attributes(
275 {'User7': make_user()}),
276 expected='User7')),
277 ('Multiple blank users', dict(
278 user_attributes=make_attributes({
279 'User7': make_user(),
280 'User8': make_user()}),
281 expected='User7')),
282 ('One not blank user', dict(
283 user_attributes=make_attributes(
284 {'User7': make_user({'Username': 'foo'})}),
285 expected=None)),
286 ('One enabled blank user', dict(
287 user_attributes=make_attributes({
288 'User7': {'Enable_User': 'Yes'}}),
289 expected='User7')),
290 ('Skip User1', dict(
291 user_attributes=make_attributes(
292 {'User1': make_user()}),
293 expected=None))
294 ]
295
296 def fetch_user_record(self, user_number):
297 """Return the mock user data for a user_number."""
298 return self.user_attributes[user_number]
299
300 def test_user_choice(self):
301 """Ensure the correct user, if any, is chosen."""
302
303 mock = self.patch(maas_ipmi_autodetect, 'get_user_record')
304 mock.side_effect = self.fetch_user_record
305 current_users = self.user_attributes.keys()
306 user = pick_user_number_from_list('maas', current_users)
307 self.assertEqual(self.expected, user)
308
309
310class TestPickUserNumber(MAASTestCase):
311 """Tests for pick_user_number()."""
312
313 def test_pick_user_number(self):
314 """Ensure proper listing and selection of a user."""
315 lun_mock = self.patch(maas_ipmi_autodetect, 'list_user_numbers')
316 lun_mock.return_value = ['User1', 'User2']
317 punfl_mock = self.patch(maas_ipmi_autodetect,
318 'pick_user_number_from_list')
319 punfl_mock.return_value = 'User2'
320 user_number = pick_user_number('maas')
321 self.assertEqual('User2', user_number)
322
323 def test_fail_raise_exception(self):
324 """Ensure an exception is raised if no acceptable user is found."""
325 mock = self.patch(maas_ipmi_autodetect, 'list_user_numbers')
326 mock.return_value = []
327 self.assertRaises(IPMIUserError, pick_user_number, 'maas')