Skip to content

Commit 5a529ca

Browse files
committed
Add support for root device hints
This patch add support for root device hints on IPA. Instead of picking the first disk >= 4G, if the hints are specified IPA will look at it to decide which device it should pick for the deployment. The initial patch supports the following hints: Size, model, WWN, serial, vendor. Implements: blueprint ipa-as-default-ramdisk Implements: blueprint root-device-hints Change-Id: I2b00b3fb3b61001033750dd8951f9353d6f2e361
1 parent 8173927 commit 5a529ca

File tree

5 files changed

+238
-9
lines changed

5 files changed

+238
-9
lines changed

ironic_python_agent/hardware.py

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import shlex
1919

2020
import netifaces
21+
from oslo_utils import units
2122
import psutil
23+
import pyudev
2224
import six
2325
import stevedore
2426

@@ -256,15 +258,100 @@ def list_block_devices(self):
256258
rotational=bool(int(device['ROTA']))))
257259
return devices
258260

261+
def _get_device_vendor(self, dev):
262+
"""Get the vendor name of a given device."""
263+
try:
264+
devname = os.path.basename(dev)
265+
with open('/sys/class/block/%s/device/vendor' % devname, 'r') as f:
266+
return f.read().strip()
267+
except IOError:
268+
LOG.warning("Can't find the device vendor for device %s", dev)
269+
259270
def get_os_install_device(self):
260-
# Find the first device larger than 4GB, assume it is the OS disk
261-
# TODO(russellhaering): This isn't a valid assumption in all cases,
262-
# is there a more reasonable default behavior?
263271
block_devices = self.list_block_devices()
264-
block_devices.sort(key=lambda device: device.size)
265-
for device in block_devices:
266-
if device.size >= (4 * pow(1024, 3)):
267-
return device.name
272+
root_device_hints = utils.parse_root_device_hints()
273+
274+
if not root_device_hints:
275+
# If no hints are passed find the first device larger than
276+
# 4GB, assume it is the OS disk
277+
# TODO(russellhaering): This isn't a valid assumption in
278+
# all cases, is there a more reasonable default behavior?
279+
block_devices.sort(key=lambda device: device.size)
280+
for device in block_devices:
281+
if device.size >= (4 * pow(1024, 3)):
282+
return device.name
283+
else:
284+
285+
def match(hint, current_value, device):
286+
hint_value = root_device_hints[hint]
287+
if hint_value != current_value:
288+
LOG.debug("Root device hint %(hint)s=%(value)s does not "
289+
"match the device %(device)s value of "
290+
"%(current)s", {'hint': hint,
291+
'value': hint_value, 'device': device,
292+
'current': current_value})
293+
return False
294+
return True
295+
296+
context = pyudev.Context()
297+
for dev in block_devices:
298+
try:
299+
udev = pyudev.Device.from_device_file(context, dev.name)
300+
except (ValueError, EnvironmentError) as e:
301+
LOG.warning("Device %(dev)s is inaccessible, skipping... "
302+
"Error: %(error)s", {'dev': dev, 'error': e})
303+
continue
304+
305+
# TODO(lucasagomes): Add support for operators <, >, =, etc...
306+
# to better deal with sizes.
307+
if 'size' in root_device_hints:
308+
# Since we don't support units yet we expect the size
309+
# in GiB for now
310+
size = dev.size / units.Gi
311+
if not match('size', size, dev.name):
312+
continue
313+
314+
if 'model' in root_device_hints:
315+
model = udev.get('ID_MODEL', None)
316+
if not model:
317+
continue
318+
model = utils.normalize(model)
319+
if not match('model', model, dev.name):
320+
continue
321+
322+
if 'wwn' in root_device_hints:
323+
wwn = udev.get('ID_WWN', None)
324+
if not wwn:
325+
continue
326+
wwn = utils.normalize(wwn)
327+
if not match('wwn', wwn, dev.name):
328+
continue
329+
330+
if 'serial' in root_device_hints:
331+
# TODO(lucasagomes): Since lsblk only supports
332+
# returning the short serial we are using
333+
# ID_SERIAL_SHORT here to keep compatibility with the
334+
# bash deploy ramdisk
335+
serial = udev.get('ID_SERIAL_SHORT', None)
336+
if not serial:
337+
continue
338+
serial = utils.normalize(serial)
339+
if not match('serial', serial, dev.name):
340+
continue
341+
342+
if 'vendor' in root_device_hints:
343+
vendor = self._get_device_vendor(dev.name)
344+
if not vendor:
345+
continue
346+
vendor = utils.normalize(vendor)
347+
if not match('vendor', vendor, dev.name):
348+
continue
349+
350+
return dev.name
351+
352+
else:
353+
raise errors.DeviceNotFound("No suitable device was found for "
354+
"deployment using these hints %s" % root_device_hints)
268355

269356
def erase_block_device(self, block_device):
270357
if self._ata_erase(block_device):

ironic_python_agent/tests/hardware.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import mock
1616
from oslotest import base as test_base
17+
import pyudev
1718
import six
1819
from stevedore import extension
1920

@@ -115,7 +116,7 @@
115116
BLK_DEVICE_TEMPLATE = (
116117
'KNAME="sda" MODEL="TinyUSB Drive" SIZE="3116853504" '
117118
'ROTA="0" TYPE="disk"\n'
118-
'KNAME="sdb" MODEL="Fastable SD131 7" SIZE="31016853504" '
119+
'KNAME="sdb" MODEL="Fastable SD131 7" SIZE="10737418240" '
119120
'ROTA="0" TYPE="disk"\n'
120121
'KNAME="sdc" MODEL="NWD-BLP4-1600 " SIZE="1765517033472" '
121122
' ROTA="0" TYPE="disk"\n'
@@ -188,6 +189,61 @@ def test_get_os_install_device(self, mocked_execute):
188189
mocked_execute.assert_called_once_with(
189190
'lsblk', '-PbdioKNAME,MODEL,SIZE,ROTA,TYPE', check_exit_code=[0])
190191

192+
@mock.patch.object(hardware.GenericHardwareManager, '_get_device_vendor')
193+
@mock.patch.object(pyudev.Device, 'from_device_file')
194+
@mock.patch.object(utils, 'parse_root_device_hints')
195+
@mock.patch.object(utils, 'execute')
196+
def test_get_os_install_device_root_device_hints(self, mocked_execute,
197+
mock_root_device,
198+
mock_pyudev,
199+
mock_dev_vendor):
200+
model = 'fastable sd131 7'
201+
mock_root_device.return_value = {'model': model,
202+
'wwn': 'fake-wwn',
203+
'serial': 'fake-serial',
204+
'vendor': 'fake-vendor',
205+
'size': 10}
206+
mock_dev_vendor.return_value = 'fake-vendor'
207+
mock_pyudev.side_effect = ({}, {'ID_MODEL': model,
208+
'ID_WWN': 'fake-wwn',
209+
'ID_SERIAL_SHORT': 'fake-serial'})
210+
mocked_execute.return_value = (BLK_DEVICE_TEMPLATE, '')
211+
212+
self.assertEqual('/dev/sdb', self.hardware.get_os_install_device())
213+
mocked_execute.assert_called_once_with(
214+
'lsblk', '-PbdioKNAME,MODEL,SIZE,ROTA,TYPE', check_exit_code=[0])
215+
mock_root_device.assert_called_once_with()
216+
expected = [mock.call(mock.ANY, '/dev/sda'),
217+
mock.call(mock.ANY, '/dev/sdb')]
218+
mock_pyudev.assert_has_calls(expected)
219+
220+
@mock.patch.object(pyudev.Device, 'from_device_file')
221+
@mock.patch.object(utils, 'parse_root_device_hints')
222+
@mock.patch.object(utils, 'execute')
223+
def test_get_os_install_device_root_device_hints_no_device_found(self,
224+
mocked_execute, mock_root_device, mock_pyudev):
225+
mock_root_device.return_value = {'model': 'endo-sym armor'}
226+
mock_pyudev.return_value = {'ID_MODEL': 'Saturn V Armor'}
227+
mocked_execute.return_value = (BLK_DEVICE_TEMPLATE, '')
228+
self.assertRaises(errors.DeviceNotFound,
229+
self.hardware.get_os_install_device)
230+
mocked_execute.assert_called_once_with(
231+
'lsblk', '-PbdioKNAME,MODEL,SIZE,ROTA,TYPE', check_exit_code=[0])
232+
mock_root_device.assert_called_once_with()
233+
expected = [mock.call(mock.ANY, '/dev/sda'),
234+
mock.call(mock.ANY, '/dev/sdb'),
235+
mock.call(mock.ANY, '/dev/sdc'),
236+
mock.call(mock.ANY, '/dev/sdd')]
237+
mock_pyudev.assert_has_calls(expected)
238+
239+
def test__get_device_vendor(self):
240+
fileobj = mock.mock_open(read_data='fake-vendor')
241+
with mock.patch(OPEN_FUNCTION_NAME, fileobj, create=True) as mock_open:
242+
vendor = self.hardware._get_device_vendor('/dev/sdfake')
243+
mock_open.assert_called_once_with(
244+
'/sys/class/block/sdfake/device/vendor', 'r')
245+
self.assertEqual('fake-vendor', vendor)
246+
191247
@mock.patch('ironic_python_agent.hardware.GenericHardwareManager.'
192248
'_get_cpu_count')
193249
@mock.patch(OPEN_FUNCTION_NAME)
@@ -298,7 +354,7 @@ def test_list_block_device(self, mocked_execute):
298354
rotational=False),
299355
hardware.BlockDevice(name='/dev/sdb',
300356
model='Fastable SD131 7',
301-
size=31016853504,
357+
size=10737418240,
302358
rotational=False),
303359
hardware.BlockDevice(name='/dev/sdc',
304360
model='NWD-BLP4-1600',

ironic_python_agent/tests/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,39 @@ def test__get_vmedia_params_umount_fails(self, execute_mock, mkdir_mock,
268268
read_params_mock.assert_called_once_with("/vmedia_mnt/parameters.txt")
269269
execute_mock.assert_any_call('umount', vmedia_mount_point)
270270
self.assertEqual(expected_params, returned_params)
271+
272+
@mock.patch.object(utils, 'get_agent_params')
273+
def test_parse_root_device_hints(self, mock_get_params):
274+
mock_get_params.return_value = {
275+
'root_device': 'vendor=SpongeBob,model=Square%20Pants',
276+
'ipa-api-url': 'http://1.2.3.4:1234'
277+
}
278+
expected = {'vendor': 'spongebob', 'model': 'square pants'}
279+
result = utils.parse_root_device_hints()
280+
self.assertEqual(expected, result)
281+
282+
@mock.patch.object(utils, 'get_agent_params')
283+
def test_parse_root_device_hints_no_hints(self, mock_get_params):
284+
mock_get_params.return_value = {
285+
'ipa-api-url': 'http://1.2.3.4:1234'
286+
}
287+
result = utils.parse_root_device_hints()
288+
self.assertEqual({}, result)
289+
290+
@mock.patch.object(utils, 'get_agent_params')
291+
def test_parse_root_device_size(self, mock_get_params):
292+
mock_get_params.return_value = {
293+
'root_device': 'size=12345',
294+
'ipa-api-url': 'http://1.2.3.4:1234'
295+
}
296+
result = utils.parse_root_device_hints()
297+
self.assertEqual(12345, result['size'])
298+
299+
@mock.patch.object(utils, 'get_agent_params')
300+
def test_parse_root_device_not_supported(self, mock_get_params):
301+
mock_get_params.return_value = {
302+
'root_device': 'foo=bar,size=12345',
303+
'ipa-api-url': 'http://1.2.3.4:1234'
304+
}
305+
self.assertRaises(errors.DeviceNotFound,
306+
utils.parse_root_device_hints)

ironic_python_agent/utils.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import os
1818

1919
from oslo_concurrency import processutils
20+
from six.moves.urllib import parse
2021

2122
from ironic_python_agent import errors
2223
from ironic_python_agent.openstack.common import _i18n as gtu
@@ -25,6 +26,9 @@
2526
LOG = logging.getLogger(__name__)
2627

2728

29+
SUPPORTED_ROOT_DEVICE_HINTS = set(('size', 'model', 'wwn', 'serial', 'vendor'))
30+
31+
2832
def get_ordereddict(*args, **kwargs):
2933
"""A fix for py26 not having ordereddict."""
3034
try:
@@ -140,3 +144,48 @@ def get_agent_params():
140144
params.update(vmedia_params)
141145

142146
return params
147+
148+
149+
def normalize(string):
150+
"""Return a normalized string."""
151+
# Since we can't use space on the kernel cmdline, Ironic will
152+
# urlencode the values.
153+
return parse.unquote(string).lower().strip()
154+
155+
156+
def parse_root_device_hints():
157+
"""Parse the root device hints.
158+
159+
Parse the root device hints given by Ironic via kernel cmdline
160+
or vmedia.
161+
162+
:returns: A dict with the hints or an empty dict if no hints are
163+
passed.
164+
:raises: DeviceNotFound if there are unsupported hints.
165+
166+
"""
167+
root_device = get_agent_params().get('root_device')
168+
if not root_device:
169+
return {}
170+
171+
hints = dict((item.split('=') for item in root_device.split(',')))
172+
173+
# Find invalid hints for logging
174+
not_supported = set(hints) - SUPPORTED_ROOT_DEVICE_HINTS
175+
if not_supported:
176+
error_msg = ('No device can be found because the following hints: '
177+
'"%(not_supported)s" are not supported by this version '
178+
'of IPA. Supported hints are: "%(supported)s"',
179+
{'not_supported': ', '.join(not_supported),
180+
'supported': ', '.join(SUPPORTED_ROOT_DEVICE_HINTS)})
181+
raise errors.DeviceNotFound(error_msg)
182+
183+
# Normalise the values
184+
hints = {k: normalize(v) for k, v in hints.iteritems()}
185+
186+
if 'size' in hints:
187+
# NOTE(lucasagomes): Ironic should validate before passing to
188+
# the deploy ramdisk
189+
hints['size'] = int(hints['size'])
190+
191+
return hints

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ oslo.serialization>=1.2.0 # Apache-2.0
1313
oslo.utils>=1.2.0 # Apache-2.0
1414
pecan>=0.8.0
1515
psutil>=1.1.1,<2.0.0
16+
pyudev
1617
requests>=2.2.0,!=2.4.0
1718
six>=1.9.0
1819
stevedore>=1.1.0 # Apache-2.0

0 commit comments

Comments
 (0)