Add Mistral action & workflow for root device configuration

The root device configuration code and unit tests come from the
python-tripleoclient project. By moving them into a common Mistral
action, they can be reused by other clients like the UI. The workflows
will also be used for the new 'overcloud node configure' command in the
client.

Change-Id: I5ba0a3710012c44822dd3b8e69662bbef04d3787
Related-Bug: #1595205
This commit is contained in:
Julie Pichon 2016-08-16 14:36:35 +01:00
parent a9c8ffb655
commit cbe0dbf32d
7 changed files with 461 additions and 0 deletions

View File

@ -13,3 +13,4 @@ python-glanceclient>=2.0.0 # Apache-2.0
python-ironicclient>=1.1.0 # Apache-2.0
six>=1.9.0 # MIT
mistral!=2015.1.0,>=2.0.0 # Apache-2.0
python-ironic-inspector-client>=1.5.0 # Apache-2.0

View File

@ -59,6 +59,7 @@ mistral.actions =
tripleo.upload_default_templates = tripleo_common.actions.templates:UploadTemplatesAction
tripleo.process_templates = tripleo_common.actions.templates:ProcessTemplatesAction
tripleo.baremetal.configure_boot = tripleo_common.actions.baremetal:ConfigureBootAction
tripleo.baremetal.configure_root_device = tripleo_common.actions.baremetal:ConfigureRootDeviceAction
tripleo.create_container = tripleo_common.actions.plan:CreateContainerAction
tripleo.create_plan = tripleo_common.actions.plan:CreatePlanAction
tripleo.deploy_config = tripleo_common.actions.deployment:OrchestrationDeployAction

View File

@ -14,8 +14,12 @@
# under the License.
import logging
import ironic_inspector_client
from mistral.workflow import utils as mistral_workflow_utils
from oslo_utils import units
from tripleo_common.actions import base
from tripleo_common import exception
from tripleo_common.utils import glance
from tripleo_common.utils import nodes
@ -131,3 +135,125 @@ class ConfigureBootAction(base.TripleOAction):
except Exception as err:
LOG.exception("Error configuring node boot options with Ironic.")
return mistral_workflow_utils.Result("", err)
class ConfigureRootDeviceAction(base.TripleOAction):
"""Configure the root device strategy.
:param node_uuid: an Ironic node UUID
:param root_device: Define the root device for nodes. Can be either a list
of device names (without /dev) to choose from or one
of two strategies: largest or smallest. For it to work
this command should be run after the introspection.
:param minimum_size: Minimum size (in GiB) of the detected root device.
:param overwrite: Whether to overwrite existing root device hints when
root-device is set.
"""
def __init__(self, node_uuid, root_device=None, minimum_size=4,
overwrite=False):
super(ConfigureRootDeviceAction, self).__init__()
self.node_uuid = node_uuid
self.root_device = root_device
self.minimum_size = minimum_size
self.overwrite = overwrite
def run(self):
if not self.root_device:
return
baremetal_client = self._get_baremetal_client()
node = baremetal_client.node.get(self.node_uuid)
self._apply_root_device_strategy(
node, self.root_device, self.minimum_size, self.overwrite)
def _apply_root_device_strategy(self, node, strategy, minimum_size,
overwrite=False):
if node.properties.get('root_device') and not overwrite:
# This is a correct situation, we still want to allow people to
# fine-tune the root device setting for a subset of nodes.
# However, issue a warning, so that they know which nodes were not
# updated during this run.
LOG.warning('Root device hints are already set for node %s '
'and overwriting is not requested, skipping',
node.uuid)
LOG.warning('You may unset them by running $ ironic '
'node-update %s remove properties/root_device',
node.uuid)
return
inspector_client = self._get_baremetal_introspection_client()
try:
data = inspector_client.get_data(node.uuid)
except ironic_inspector_client.ClientError:
raise exception.RootDeviceDetectionError(
'No introspection data found for node %s, '
'root device cannot be detected' % node.uuid)
except AttributeError:
raise RuntimeError('Ironic inspector client version 1.2.0 or '
'newer is required for detecting root device')
try:
disks = data['inventory']['disks']
except KeyError:
raise exception.RootDeviceDetectionError(
'Malformed introspection data for node %s: '
'disks list is missing' % node.uuid)
minimum_size *= units.Gi
disks = [d for d in disks if d.get('size', 0) >= minimum_size]
if not disks:
raise exception.RootDeviceDetectionError(
'No suitable disks found for node %s' % node.uuid)
if strategy == 'smallest':
disks.sort(key=lambda d: d['size'])
root_device = disks[0]
elif strategy == 'largest':
disks.sort(key=lambda d: d['size'], reverse=True)
root_device = disks[0]
else:
disk_names = [x.strip() for x in strategy.split(',')]
disks = {d['name']: d for d in disks}
for candidate in disk_names:
try:
root_device = disks['/dev/%s' % candidate]
except KeyError:
continue
else:
break
else:
raise exception.RootDeviceDetectionError(
'Cannot find a disk with any of names %(strategy)s '
'for node %(node)s' %
{'strategy': strategy, 'node': node.uuid})
hint = None
for hint_name in ('wwn', 'serial'):
if root_device.get(hint_name):
hint = {hint_name: root_device[hint_name]}
break
if hint is None:
# I don't think it might actually happen, but just in case
raise exception.RootDeviceDetectionError(
'Neither WWN nor serial number are known for device %(dev)s '
'on node %(node)s; root device hints cannot be used' %
{'dev': root_device['name'], 'node': node.uuid})
# During the introspection process we got local_gb assigned according
# to the default strategy. Now we need to update it.
new_size = root_device['size'] / units.Gi
# This -1 is what we always do to account for partitioning
new_size -= 1
bm_client = self._get_baremetal_client()
bm_client.node.update(
node.uuid,
[{'op': 'add', 'path': '/properties/root_device', 'value': hint},
{'op': 'add', 'path': '/properties/local_gb', 'value': new_size}])
LOG.info('Updated root device for node %(node)s, new device '
'is %(dev)s, new local_gb is %(local_gb)d',
{'node': node.uuid, 'dev': root_device, 'local_gb': new_size})

View File

@ -14,6 +14,7 @@
# under the License.
from glanceclient.v2 import client as glanceclient
from heatclient.v1 import client as heatclient
import ironic_inspector_client
from ironicclient.v1 import client as ironicclient
from mistral.actions import base
from mistral import context
@ -50,6 +51,19 @@ class TripleOAction(base.Action):
os_ironic_api_version='1.11'
)
def _get_baremetal_introspection_client(self):
ctx = context.ctx()
bmi_endpoint = keystone_utils.get_endpoint_for_project(
'ironic-inspector')
return ironic_inspector_client.ClientV1(
api_version='1.2',
inspector_url=bmi_endpoint.url,
region_name=bmi_endpoint.region,
auth_token=ctx.auth_token
)
def _get_image_client(self):
ctx = context.ctx()

View File

@ -107,3 +107,7 @@ class StateTransitionFailed(Exception):
}
)
super(StateTransitionFailed, self).__init__(message)
class RootDeviceDetectionError(Exception):
"""Failed to detect the root device"""

View File

@ -14,8 +14,11 @@
import mock
from glanceclient import exc as glance_exceptions
import ironic_inspector_client
from oslo_utils import units
from tripleo_common.actions import baremetal
from tripleo_common import exception
from tripleo_common.tests import base
@ -135,3 +138,195 @@ class TestConfigureBootAction(base.TestCase):
result = action.run()
self.assertIn("Update error", str(result.error))
class TestConfigureRootDeviceAction(base.TestCase):
def setUp(self):
super(TestConfigureRootDeviceAction, self).setUp()
# Mock data
self.disks = [
{'name': '/dev/sda', 'size': 11 * units.Gi},
{'name': '/dev/sdb', 'size': 2 * units.Gi},
{'name': '/dev/sdc', 'size': 5 * units.Gi},
{'name': '/dev/sdd', 'size': 21 * units.Gi},
{'name': '/dev/sde', 'size': 13 * units.Gi},
]
for i, disk in enumerate(self.disks):
disk['wwn'] = 'wwn%d' % i
disk['serial'] = 'serial%d' % i
# Ironic mocks
self.ironic = mock.MagicMock()
ironic_patcher = mock.patch(
'tripleo_common.actions.base.TripleOAction._get_baremetal_client',
return_value=self.ironic)
self.mock_ironic = ironic_patcher.start()
self.addCleanup(self.mock_ironic.stop)
self.ironic.node.list.return_value = [
mock.Mock(uuid="ABCDEFGH"),
]
self.node = mock.Mock(uuid="ABCDEFGH", properties={})
self.ironic.node.get.return_value = self.node
# inspector mocks
self.inspector = mock.MagicMock()
inspector_patcher = mock.patch(
'tripleo_common.actions.base.TripleOAction.'
'_get_baremetal_introspection_client',
return_value=self.inspector)
self.mock_inspector = inspector_patcher.start()
self.addCleanup(self.mock_inspector.stop)
self.inspector.get_data.return_value = {
'inventory': {'disks': self.disks}
}
def test_smallest(self):
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest')
action.run()
self.assertEqual(self.ironic.node.update.call_count, 1)
root_device_args = self.ironic.node.update.call_args_list[0]
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
'value': {'wwn': 'wwn2'}},
{'op': 'add', 'path': '/properties/local_gb',
'value': 4}]
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
root_device_args)
def test_largest(self):
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='largest')
action.run()
self.assertEqual(self.ironic.node.update.call_count, 1)
root_device_args = self.ironic.node.update.call_args_list[0]
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
'value': {'wwn': 'wwn3'}},
{'op': 'add', 'path': '/properties/local_gb',
'value': 20}]
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
root_device_args)
def test_no_overwrite(self):
self.node.properties['root_device'] = {'foo': 'bar'}
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest')
action.run()
self.assertEqual(self.ironic.node.update.call_count, 0)
def test_with_overwrite(self):
self.node.properties['root_device'] = {'foo': 'bar'}
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest',
overwrite=True)
action.run()
self.assertEqual(self.ironic.node.update.call_count, 1)
root_device_args = self.ironic.node.update.call_args_list[0]
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
'value': {'wwn': 'wwn2'}},
{'op': 'add', 'path': '/properties/local_gb',
'value': 4}]
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
root_device_args)
def test_minimum_size(self):
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest',
minimum_size=10)
action.run()
self.assertEqual(self.ironic.node.update.call_count, 1)
root_device_args = self.ironic.node.update.call_args_list[0]
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
'value': {'wwn': 'wwn0'}},
{'op': 'add', 'path': '/properties/local_gb',
'value': 10}]
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
root_device_args)
def test_bad_inventory(self):
self.inspector.get_data.return_value = {}
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest')
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
"Malformed introspection data",
action.run)
self.assertEqual(self.ironic.node.update.call_count, 0)
def test_no_disks(self):
self.inspector.get_data.return_value = {
'inventory': {
'disks': [{'name': '/dev/sda', 'size': 1 * units.Gi}]
}
}
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest')
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
"No suitable disks",
action.run)
self.assertEqual(self.ironic.node.update.call_count, 0)
def test_no_data(self):
self.inspector.get_data.side_effect = (
ironic_inspector_client.ClientError(mock.Mock()))
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest')
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
"No introspection data",
action.run)
self.assertEqual(self.ironic.node.update.call_count, 0)
def test_no_wwn_and_serial(self):
self.inspector.get_data.return_value = {
'inventory': {
'disks': [{'name': '/dev/sda', 'size': 10 * units.Gi}]
}
}
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='smallest')
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
"Neither WWN nor serial number are known",
action.run)
self.assertEqual(self.ironic.node.update.call_count, 0)
def test_device_list(self):
action = baremetal.ConfigureRootDeviceAction(
node_uuid='MOCK_UUID',
root_device='hda,sda,sdb,sdc')
action.run()
self.assertEqual(self.ironic.node.update.call_count, 1)
root_device_args = self.ironic.node.update.call_args_list[0]
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
'value': {'wwn': 'wwn0'}},
{'op': 'add', 'path': '/properties/local_gb',
'value': 10}]
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
root_device_args)
def test_device_list_not_found(self):
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
root_device='hda')
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
"Cannot find a disk",
action.run)
self.assertEqual(self.ironic.node.update.call_count, 0)

View File

@ -267,3 +267,123 @@ workflows:
message: <% $.get('message', '') %>
execution: <% execution() %>
introspected_nodes: <% $.get('introspected_nodes', []) %>
configure:
description: Take a list of manageable nodes and update their boot configuration.
input:
- node_uuids
- queue_name: tripleo
- kernel_name: bm-deploy-kernel
- ramdisk_name: bm-deploy-ramdisk
- instance_boot_option: null
- root_device: null
- root_device_minimum_size: 4
- overwrite_root_device_hints: False
tasks:
configure_boot:
on-success: configure_root_device
on-error: set_status_failed_configure_boot
with-items: node_uuid in <% $.node_uuids %>
action: tripleo.baremetal.configure_boot node_uuid=<% $.node_uuid %> kernel_name=<% $.kernel_name %> ramdisk_name=<% $.ramdisk_name %> instance_boot_option=<% $.instance_boot_option %>
publish:
status: SUCCESS
message: 'Successfully configured boot options.'
configure_root_device:
on-success: send_message
on-error: set_status_failed_configure_root_device
with-items: node_uuid in <% $.node_uuids %>
action: tripleo.baremetal.configure_root_device node_uuid=<% $.node_uuid %> root_device=<% $.root_device %> minimum_size=<% $.root_device_minimum_size %> overwrite=<% $.overwrite_root_device_hints %>
publish:
status: SUCCESS
message: 'Successfully configured root device.'
set_status_failed_configure_boot:
on-success: send_message
publish:
status: FAILED
message: <% task(configure_boot).result %>
set_status_failed_configure_root_device:
on-success: send_message
publish:
status: FAILED
message: <% task(configure_root_device).result %>
send_message:
action: zaqar.queue_post
input:
queue_name: <% $.queue_name %>
messages:
body:
type: tripleo.baremetal.v1.configure
payload:
status: <% $.get('status', 'SUCCESS') %>
message: <% $.get('message', '') %>
execution: <% execution() %>
configure_manageable_nodes:
description: Update the boot configuration of all nodes in 'manageable' state.
input:
- queue_name: tripleo
- kernel_name: 'bm-deploy-kernel'
- ramdisk_name: 'bm-deploy-ramdisk'
- instance_boot_option: null
- root_device: null
- root_device_minimum_size: 4
- overwrite_root_device_hints: False
tasks:
get_manageable_nodes:
action: ironic.node_list maintenance=False associated=False
on-success: configure_manageable
on-error: set_status_failed_get_manageable_nodes
publish:
managed_nodes: <% task(get_manageable_nodes).result.where($.provision_state = 'manageable').uuid %>
configure_manageable:
on-success: send_message
on-error: set_status_failed_configure_manageable
workflow: tripleo.baremetal.v1.configure
input:
node_uuids: <% $.managed_nodes %>
queue_name: <% $.queue_name %>
kernel_name: <% $.kernel_name %>
ramdisk_name: <% $.ramdisk_name %>
instance_boot_option: <% $.instance_boot_option %>
root_device: <% $.root_device %>
root_device_minimum_size: <% $.root_device_minimum_size %>
overwrite_root_device_hints: <% $.overwrite_root_device_hints %>
publish:
message: 'Manageable nodes configured successfully.'
set_status_failed_configure_manageable:
on-success: send_message
publish:
status: FAILED
message: <% task(configure_manageable).result %>
set_status_failed_get_manageable_nodes:
on-success: send_message
publish:
status: FAILED
message: <% task(get_manageable_nodes).result %>
send_message:
action: zaqar.queue_post
input:
queue_name: <% $.queue_name %>
messages:
body:
type: tripleo.baremetal.v1.configure_manageable_nodes
payload:
status: <% $.get('status', 'SUCCESS') %>
message: <% $.get('message', '') %>
execution: <% execution() %>